* feat(ai): add HarnessAgent sidecar foundation * feat(ai): integrate Pi as a Harness provider * fix(ai): harden Harness provider integration * fix(ai): address Harness integration review * fix(ai): align Harness code with project conventions * fix(ai): preserve Harness sessions on cancellation * test(ai): cover Harness JSONL framing * refactor(ai): generalize Harness sidecar adapters * fix(ci): build Harness sidecar for native checks
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { AI_PROVIDERS } from '@open-pencil/core/constants'
|
|
|
|
import { resolveLanguageModelID } from '@/app/ai/chat/model'
|
|
import { normalizeOpenRouterModel } from '@/app/ai/chat/provider-models'
|
|
import { createAnthropicCompatibleAdapter } from '@/app/ai/providers/compatible'
|
|
import { modelProviderAdapter } from '@/app/ai/providers/registry'
|
|
import type { ModelConfig } from '@/app/ai/providers/types'
|
|
|
|
describe('resolveLanguageModelID', () => {
|
|
test('uses the selected OpenRouter model when no custom model is configured', () => {
|
|
expect(
|
|
resolveLanguageModelID({
|
|
providerID: 'openrouter',
|
|
modelID: 'anthropic/claude-sonnet-4.6',
|
|
customModelID: ''
|
|
})
|
|
).toBe('anthropic/claude-sonnet-4.6')
|
|
})
|
|
|
|
test('uses a custom OpenRouter model ID when provided', () => {
|
|
expect(
|
|
resolveLanguageModelID({
|
|
providerID: 'openrouter',
|
|
modelID: 'anthropic/claude-sonnet-4.6',
|
|
customModelID: ' meta-llama/llama-3.3-70b-instruct '
|
|
})
|
|
).toBe('meta-llama/llama-3.3-70b-instruct')
|
|
})
|
|
})
|
|
|
|
describe('model provider registry', () => {
|
|
test('offers MiniMax-M3 as the default MiniMax model', () => {
|
|
const provider = AI_PROVIDERS.find(({ id }) => id === 'minimax')
|
|
expect(provider?.defaultModel).toBe('MiniMax-M3')
|
|
expect(provider?.models[0]).toMatchObject({ id: 'MiniMax-M3' })
|
|
})
|
|
|
|
test('sends MiniMax-M3 through the OpenAI-compatible chat endpoint', async () => {
|
|
let requestURL = ''
|
|
let requestBody = ''
|
|
const fetchSpy: typeof fetch = async (input, init) => {
|
|
requestURL = String(input)
|
|
requestBody = String(init?.body)
|
|
throw new Error('stop')
|
|
}
|
|
const config: ModelConfig = {
|
|
providerID: 'minimax',
|
|
apiKey: 'test-key',
|
|
modelID: 'MiniMax-M3',
|
|
customModelID: '',
|
|
customBaseURL: '',
|
|
customAPIType: 'completions'
|
|
}
|
|
|
|
const model = modelProviderAdapter('minimax').create(config, { fetch: fetchSpy })
|
|
await model
|
|
.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] })
|
|
.catch(() => undefined)
|
|
|
|
expect(requestURL).toBe('https://api.minimax.io/v1/chat/completions')
|
|
expect(requestBody).toContain('"model":"MiniMax-M3"')
|
|
})
|
|
|
|
test('registers every direct provider without handling agent runtimes as models', () => {
|
|
for (const provider of AI_PROVIDERS.filter((entry) => entry.id !== 'harness:pi')) {
|
|
expect(modelProviderAdapter(provider.id).create).toBeFunction()
|
|
}
|
|
expect(() => modelProviderAdapter('harness:pi')).toThrow('Harness agents')
|
|
expect(() => modelProviderAdapter('acp:claude-code')).toThrow(
|
|
'ACP providers and Harness agents do not use direct API models'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('anthropic compatible adapter', () => {
|
|
const config: ModelConfig = {
|
|
providerID: 'anthropic',
|
|
apiKey: 'test-key',
|
|
modelID: 'claude-sonnet-4.6',
|
|
customModelID: '',
|
|
customBaseURL: '',
|
|
customAPIType: 'completions'
|
|
}
|
|
|
|
async function capturedHeaders(): Promise<Record<string, string>> {
|
|
let captured: Record<string, string> = {}
|
|
const fetchSpy: typeof fetch = async (_input, init) => {
|
|
captured = Object.fromEntries(new Headers(init?.headers).entries())
|
|
throw new Error('stop')
|
|
}
|
|
|
|
const model = createAnthropicCompatibleAdapter().create(config, { fetch: fetchSpy })
|
|
await model
|
|
.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] })
|
|
.catch(() => undefined)
|
|
|
|
return captured
|
|
}
|
|
|
|
test('opts into direct browser access so Anthropic returns CORS headers', async () => {
|
|
// Anthropic omits Access-Control-Allow-Origin unless this header is sent, which makes the
|
|
// web build fail with an opaque network error. See #436.
|
|
expect(await capturedHeaders()).toHaveProperty(
|
|
'anthropic-dangerous-direct-browser-access',
|
|
'true'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('normalizeOpenRouterModel', () => {
|
|
test('keeps tool-capable OpenRouter models', () => {
|
|
expect(
|
|
normalizeOpenRouterModel({
|
|
id: 'meta-llama/llama-3.3-70b-instruct',
|
|
name: 'Llama 3.3 70B Instruct',
|
|
supported_parameters: ['tools'],
|
|
architecture: { input_modalities: ['text', 'image'] },
|
|
top_provider: { max_completion_tokens: 32_768 }
|
|
})
|
|
).toEqual({
|
|
id: 'meta-llama/llama-3.3-70b-instruct',
|
|
name: 'Llama 3.3 70B Instruct',
|
|
capabilities: ['tools', 'vision'],
|
|
recommendedMaxOutputTokens: 32_768
|
|
})
|
|
})
|
|
|
|
test('skips OpenRouter models without tool support', () => {
|
|
expect(
|
|
normalizeOpenRouterModel({
|
|
id: 'text-only/model',
|
|
name: 'Text Only',
|
|
supported_parameters: []
|
|
})
|
|
).toBeNull()
|
|
})
|
|
})
|