Phase 0: validate AI SDK v6 with Zen gateway — 31 tests, all green
This commit is contained in:
407
OPENCODE_REFACTOR.md
Normal file
407
OPENCODE_REFACTOR.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# AI Integration Rewrite
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Delete `OpenCodeManager.ts` (2,745 lines) and `streaming.ts` (621 lines). Replace all AI plumbing with **Vercel AI SDK v6**. Multi-provider from day 1.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- AI SDK owns all protocol work: streaming, retry, token tracking, message format, tool loop
|
||||||
|
- We own: tools, prompts, persistence, key management, A2UI, model catalog
|
||||||
|
- No provider-specific code in business logic — AI SDK abstracts providers
|
||||||
|
- Zod schemas shared between AI SDK `tool()` and MCP server — single source of truth
|
||||||
|
- Provider = configuration, not code. Adding Anthropic Direct or OpenAI Direct = adding a config entry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/engine/
|
||||||
|
├── ai/
|
||||||
|
│ ├── providers.ts # Provider registry, model resolution
|
||||||
|
│ ├── blog-tools.ts # 16 data tools (shared with MCP)
|
||||||
|
│ ├── a2ui-tools.ts # 7 render_* tools
|
||||||
|
│ ├── chat.ts # sendMessage, abort, title gen (streamText)
|
||||||
|
│ └── tasks.ts # One-shot: taxonomy, image analysis (generateText)
|
||||||
|
├── MCPServer.ts # Imports blog-tools.ts — zero duplication
|
||||||
|
├── ChatEngine.ts # Unchanged
|
||||||
|
├── ModelCatalogEngine.ts # Unchanged
|
||||||
|
├── SecureKeyStore.ts # Extended for multi-provider keys
|
||||||
|
└── a2ui/ # Unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE entirely
|
||||||
|
|
||||||
|
| File | Lines | Why |
|
||||||
|
|------|-------|-----|
|
||||||
|
| `OpenCodeManager.ts` | 2,745 | Replaced by `ai/` modules |
|
||||||
|
| `streaming.ts` | 621 | AI SDK providers handle all streaming |
|
||||||
|
| MCPServer duplicated tools | ~165 | Uses `blog-tools.ts` |
|
||||||
|
| **Total** | **~3,530** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider System
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mistral
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider types
|
||||||
|
|
||||||
|
| Provider | SDK package | baseURL | Models | Key |
|
||||||
|
|----------|-------------|---------|--------|-----|
|
||||||
|
| OpenCode (gateway) | `@ai-sdk/anthropic` + `@ai-sdk/openai` | Zen URLs | claude\*, gpt\*, gemini\*, o3\*, o4\* | OpenCode key |
|
||||||
|
| Mistral (direct) | `@ai-sdk/mistral` | default | mistral\*, codestral\*, pixtral\* | Mistral key |
|
||||||
|
| Anthropic (direct) | `@ai-sdk/anthropic` | default | claude\* | Anthropic key |
|
||||||
|
| OpenAI (direct) | `@ai-sdk/openai` | default | gpt\*, o3\*, o4\* | OpenAI key |
|
||||||
|
|
||||||
|
Start with OpenCode + Mistral. Adding direct Anthropic/OpenAI = registering a new provider entry, zero code changes.
|
||||||
|
|
||||||
|
### OpenCode is a gateway, not a provider
|
||||||
|
|
||||||
|
OpenCode Zen exposes two API-compatible endpoints behind one key:
|
||||||
|
- `https://opencode.ai/zen/v1/messages` — Anthropic Messages API
|
||||||
|
- `https://opencode.ai/zen/v1/chat/completions` — OpenAI Chat Completions API
|
||||||
|
|
||||||
|
We use standard `@ai-sdk/anthropic` and `@ai-sdk/openai` with `baseURL` override. No community provider needed — the existing one (`ai-sdk-provider-opencode-sdk`) wraps the OpenCode CLI, not Zen.
|
||||||
|
|
||||||
|
### `ai/providers.ts`
|
||||||
|
|
||||||
|
Uses `createProviderRegistry` + `customProvider` with `fallbackProvider`. Model IDs carry a provider prefix (`opencode:claude-sonnet-4-5`, `mistral:mistral-large-latest`) — the prefix IS the routing. No static model maps.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
import { createMistral } from '@ai-sdk/mistral';
|
||||||
|
import { createProviderRegistry, customProvider } from 'ai';
|
||||||
|
|
||||||
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
||||||
|
|
||||||
|
function createOpenCodeGateway(apiKey: string) {
|
||||||
|
const anthropicProvider = createAnthropic({ baseURL: ZEN_BASE_URL, apiKey });
|
||||||
|
// CRITICAL: .chat() = Chat Completions API. Default = Responses API (incompatible with Zen).
|
||||||
|
const openaiProvider = createOpenAI({ baseURL: ZEN_BASE_URL, apiKey });
|
||||||
|
|
||||||
|
return customProvider({
|
||||||
|
fallbackProvider: {
|
||||||
|
languageModel: (modelId: string) => {
|
||||||
|
if (modelId.startsWith('claude')) return anthropicProvider(modelId);
|
||||||
|
return openaiProvider.chat(modelId); // .chat() required for Chat Completions
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegistry(keys: { opencode?: string; mistral?: string }) {
|
||||||
|
const providers: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (keys.opencode) providers.opencode = createOpenCodeGateway(keys.opencode);
|
||||||
|
if (keys.mistral) providers.mistral = createMistral({ apiKey: keys.mistral });
|
||||||
|
// Future direct providers: just add more entries
|
||||||
|
// if (keys.anthropic) providers.anthropic = createAnthropic({ apiKey: keys.anthropic });
|
||||||
|
|
||||||
|
return createProviderRegistry(providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: registry.languageModel('opencode:claude-sonnet-4-5')
|
||||||
|
// Usage: registry.languageModel('mistral:mistral-large-latest')
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway (OpenCode) routes `claude*` → Anthropic Messages API, everything else → OpenAI Chat Completions API. Direct providers (Mistral) are 1:1. Adding a new provider = one config entry, zero code changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### `ai/blog-tools.ts` — 16 data tools
|
||||||
|
|
||||||
|
Single source of truth. AI SDK `tool()` + Zod. Shared between chat and MCP.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createBlogTools(deps: BlogToolDeps) {
|
||||||
|
return {
|
||||||
|
check_term: tool({
|
||||||
|
description: 'Check whether a term exists as a category, tag, or both',
|
||||||
|
inputSchema: z.object({ term: z.string() }),
|
||||||
|
execute: async ({ term }) => { /* PostEngine queries */ },
|
||||||
|
}),
|
||||||
|
search_posts: tool({ ... }),
|
||||||
|
read_post: tool({ ... }),
|
||||||
|
list_posts: tool({ ... }),
|
||||||
|
get_media: tool({ ... }),
|
||||||
|
list_media: tool({ ... }),
|
||||||
|
update_post_metadata: tool({ ... }),
|
||||||
|
update_media_metadata: tool({ ... }),
|
||||||
|
list_tags: tool({ ... }),
|
||||||
|
list_categories: tool({ ... }),
|
||||||
|
get_blog_stats: tool({ ... }),
|
||||||
|
view_image: tool({
|
||||||
|
// Uses toModelOutput() for multimodal result — works across all providers
|
||||||
|
inputSchema: z.object({ media_id: z.number(), size: z.enum(['small','medium','large']) }),
|
||||||
|
execute: async ({ media_id, size }) => ({ base64, mediaType, caption }),
|
||||||
|
toModelOutput: ({ output }) => ({
|
||||||
|
type: 'content',
|
||||||
|
value: [
|
||||||
|
{ type: 'image', data: output.base64, mediaType: output.mediaType },
|
||||||
|
{ type: 'text', text: output.caption },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
get_post_backlinks: tool({ ... }),
|
||||||
|
get_post_outlinks: tool({ ... }),
|
||||||
|
get_post_media: tool({ ... }),
|
||||||
|
get_media_posts: tool({ ... }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared helper consumed by both tools and MCP
|
||||||
|
export function buildAmbiguityHints(...): Promise<string[]> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
MCPServer integration: `createBlogTools(deps)` → extract schemas + handlers → register as MCP tools. Zero duplication.
|
||||||
|
|
||||||
|
### `ai/a2ui-tools.ts` — 7 render tools
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createA2UITools() {
|
||||||
|
return {
|
||||||
|
render_chart: tool({ ... }),
|
||||||
|
render_table: tool({ ... }),
|
||||||
|
render_form: tool({ ... }),
|
||||||
|
render_card: tool({ ... }),
|
||||||
|
render_metric: tool({ ... }),
|
||||||
|
render_list: tool({ ... }),
|
||||||
|
render_tabs: tool({ ... }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A2UI message dispatch happens in `chat.ts` via `experimental_onToolCallFinish` — the tool itself just returns `{ success: true }`.
|
||||||
|
|
||||||
|
### `ai/chat.ts` — ChatService
|
||||||
|
|
||||||
|
The core. One `streamText()` call replaces both `sendAnthropicMessage()` and `sendOpenAIMessage()`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { streamText, stepCountIs } from 'ai';
|
||||||
|
|
||||||
|
class ChatService {
|
||||||
|
private abortControllers = new Map<string, AbortController>();
|
||||||
|
private tokenUsage = new Map<string, TokenUsage>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private chatEngine: ChatEngine,
|
||||||
|
private providers: ProviderRegistry,
|
||||||
|
private blogTools: ReturnType<typeof createBlogTools>,
|
||||||
|
private a2uiTools: ReturnType<typeof createA2UITools>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendMessage(conversationId: string, content: string, callbacks: StreamCallbacks) {
|
||||||
|
const conv = await this.chatEngine.getConversation(conversationId);
|
||||||
|
const model = this.providers.getModel(conv.model);
|
||||||
|
const ac = new AbortController();
|
||||||
|
this.abortControllers.set(conversationId, ac);
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
system: await this.buildSystemPrompt(conv),
|
||||||
|
messages: await this.loadMessages(conversationId),
|
||||||
|
tools: { ...this.blogTools, ...this.a2uiTools },
|
||||||
|
maxRetries: 3,
|
||||||
|
stopWhen: stepCountIs(10),
|
||||||
|
abortSignal: ac.signal,
|
||||||
|
|
||||||
|
// Anthropic: server-side context management (replaces truncateToTokenBudget)
|
||||||
|
providerOptions: {
|
||||||
|
anthropic: {
|
||||||
|
cacheControl: { type: 'ephemeral' }, // cache system + tools
|
||||||
|
contextManagement: {
|
||||||
|
edits: [
|
||||||
|
{ type: 'clear_tool_uses_20250919', trigger: { type: 'input_tokens', value: 50000 },
|
||||||
|
keep: { type: 'tool_uses', value: 5 }, clearToolInputs: true },
|
||||||
|
{ type: 'compact_20260112', trigger: { type: 'input_tokens', value: 80000 },
|
||||||
|
instructions: 'Summarize preserving editorial decisions and tool results.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Non-Anthropic: simple message window
|
||||||
|
prepareStep: async ({ messages }) => {
|
||||||
|
if (messages.length > 30) return { messages: [messages[0], ...messages.slice(-15)] };
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
onChunk: ({ chunk }) => {
|
||||||
|
if (chunk.type === 'text') callbacks.onDelta?.(chunk.text);
|
||||||
|
if (chunk.type === 'reasoning') callbacks.onReasoning?.(chunk.text);
|
||||||
|
},
|
||||||
|
experimental_onToolCallFinish: ({ toolCall, output }) => {
|
||||||
|
callbacks.onToolResult?.({ name: toolCall.toolName, result: output });
|
||||||
|
if (isRenderTool(toolCall.toolName)) {
|
||||||
|
const msg = generateFromToolCall(toolCall.toolName, toolCall.input);
|
||||||
|
if (msg) callbacks.onA2UIMessage?.(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStepFinish: ({ usage }) => {
|
||||||
|
this.accumulateUsage(conversationId, usage);
|
||||||
|
callbacks.onTokenUsage?.(this.tokenUsage.get(conversationId)!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist — response.messages gives clean provider-agnostic format
|
||||||
|
const messages = await result.response;
|
||||||
|
await this.chatEngine.persistMessages(conversationId, messages.messages);
|
||||||
|
this.abortControllers.delete(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(conversationId: string) {
|
||||||
|
this.abortControllers.get(conversationId)?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTitle(conversationId: string) {
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: this.providers.getModel(titleModel),
|
||||||
|
system: 'Generate a concise title...',
|
||||||
|
messages: await this.loadMessages(conversationId),
|
||||||
|
maxTokens: 60,
|
||||||
|
});
|
||||||
|
await this.chatEngine.updateTitle(conversationId, text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
~80 lines replaces ~560 lines of provider-specific streaming code.
|
||||||
|
|
||||||
|
### `ai/tasks.ts` — One-shot tasks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class OneShotTasks {
|
||||||
|
constructor(private providers: ProviderRegistry) {}
|
||||||
|
|
||||||
|
async analyzeTaxonomy(items: TaxonomyItem[], modelId: string) {
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: this.providers.getModel(modelId),
|
||||||
|
system: TAXONOMY_SYSTEM_PROMPT,
|
||||||
|
prompt: buildTaxonomyPrompt(items),
|
||||||
|
maxTokens: 4096,
|
||||||
|
});
|
||||||
|
return parseTaxonomyResponse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeMediaImage(imageBase64: string, mediaType: string, language: string, modelId: string) {
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: this.providers.getModel(modelId),
|
||||||
|
system: imageAnalysisPrompt(language),
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image', image: imageBase64, mimeType: mediaType },
|
||||||
|
{ type: 'text', text: 'Analyze. Respond with JSON.' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
maxTokens: 200,
|
||||||
|
});
|
||||||
|
return parseImageAnalysisResponse(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Carries Over
|
||||||
|
|
||||||
|
Domain logic only — no AI protocol code survives.
|
||||||
|
|
||||||
|
| What | Source | Destination |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| 16 blog tool execute functions | `OpenCodeManager.executeTool()` | `ai/blog-tools.ts` |
|
||||||
|
| 7 A2UI tool definitions | `OpenCodeManager.getToolDefinitions()` | `ai/a2ui-tools.ts` |
|
||||||
|
| System prompt construction | `OpenCodeManager.buildSystemPrompt()` | `ai/chat.ts` |
|
||||||
|
| One-shot prompts (taxonomy, image) | `OpenCodeManager.analyze*()` | `ai/tasks.ts` |
|
||||||
|
| A2UI generator + catalog | `a2ui/` | `a2ui/` (unchanged) |
|
||||||
|
| Conversation persistence | `ChatEngine` | `ChatEngine` (unchanged) |
|
||||||
|
| Model catalog | `ModelCatalogEngine` | `ModelCatalogEngine` (unchanged) |
|
||||||
|
| Key encryption | `SecureKeyStore` | `SecureKeyStore` (extended) |
|
||||||
|
| MCP proposal tools | `MCPServer` | `MCPServer` (gains shared blog-tools) |
|
||||||
|
| Model listing HTTP | `OpenCodeManager.getAvailableModels()` | `ai/providers.ts` (thin HTTP for model lists) |
|
||||||
|
|
||||||
|
## IPC Changes
|
||||||
|
|
||||||
|
### Remove (provider-specific)
|
||||||
|
- `chat:validateApiKey`, `chat:setApiKey`, `chat:getApiKey` — replaced by generic
|
||||||
|
- `chat:validateMistralApiKey`, `chat:setMistralApiKey`, `chat:getMistralApiKey` — replaced by generic
|
||||||
|
|
||||||
|
### Add (provider-agnostic)
|
||||||
|
- `chat:getProviders` — list configured provider entries
|
||||||
|
- `chat:setProviderKey` / `chat:getProviderKey` — per-provider key management
|
||||||
|
- `chat:validateProvider` — test provider connectivity
|
||||||
|
|
||||||
|
### Keep (unchanged)
|
||||||
|
- `chat:sendMessage`, `chat:abortMessage` — wire to `ChatService`
|
||||||
|
- `chat:analyzeTaxonomy`, `chat:analyzeMediaImage` — wire to `OneShotTasks`
|
||||||
|
- All conversation CRUD, model catalog, system prompt handlers
|
||||||
|
- `a2ui:dispatch`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **No façade** — IPC handlers wire directly to `ChatService`, `ProviderRegistry`, `OneShotTasks`
|
||||||
|
2. **Anthropic context management** replaces `truncateToTokenBudget()` — server-side compaction, smarter than client-side estimation
|
||||||
|
3. **Cache control** via `providerOptions.anthropic.cacheControl` at message + tool level
|
||||||
|
4. **Extended thinking** — not now, but architecture supports it (just add `providerOptions.anthropic.thinking`)
|
||||||
|
5. **Electron `fetch`** — AI SDK uses Node `fetch` (works in Electron 40). Escape hatch: `net.fetch` as custom `fetch` for proxy/SSL
|
||||||
|
6. **Provider as config** — no per-provider classes. `ProviderRegistry` maps config → AI SDK instance. Add providers without code changes
|
||||||
|
7. **`toModelOutput`** on `view_image` — single definition works for all providers, eliminates per-provider image formatting hack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Plan
|
||||||
|
|
||||||
|
### Phase 0: Validate AI SDK + Electron (1 session) ✅ DONE
|
||||||
|
1. ~~`npm install ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mistral`~~ ✅
|
||||||
|
2. ~~Write integration test: `generateText()` through Zen gateway with `baseURL` override~~ ✅ 31 tests
|
||||||
|
3. ~~Verify Electron `fetch` works (or set up `net.fetch` fallback)~~ ✅ Node fetch works
|
||||||
|
4. ~~Verify Zen baseURL path conventions match SDK expectations~~ ✅ See findings below
|
||||||
|
|
||||||
|
**Phase 0 Findings:**
|
||||||
|
- **BaseURL paths confirmed**: `@ai-sdk/anthropic` appends `/messages`, `@ai-sdk/openai` appends `/chat/completions` — Zen-compatible
|
||||||
|
- **CRITICAL: OpenAI Responses API vs Chat Completions**: `@ai-sdk/openai` v6 defaults to **Responses API** (`/responses`). Must use `provider.chat(modelId)` for Chat Completions (`/chat/completions`). All gateways (Zen, Azure, etc.) require Chat Completions.
|
||||||
|
- **`providerId:modelId` routing works**: `createProviderRegistry` resolves via prefix — no static model maps needed
|
||||||
|
- **`customProvider` with `fallbackProvider`**: Proven pattern for gateway routing with one rule: `startsWith('claude') → Anthropic, else → OpenAI`
|
||||||
|
- **Zod v4 schemas work with `tool()`**: Parameterized schemas, `toModelOutput()` for multimodal results
|
||||||
|
- **Anthropic `providerOptions`**: Cache control on system+tools, context management — all confirmed working
|
||||||
|
|
||||||
|
### Phase 1: Tools + MCP dedup (1 session)
|
||||||
|
5. Create `ai/blog-tools.ts` — 16 tools with Zod + execute (port from `executeTool` switch)
|
||||||
|
6. Create `ai/a2ui-tools.ts` — 7 render tools
|
||||||
|
7. Wire MCPServer to `blog-tools.ts` for `check_term` / `search_posts` — delete duplication
|
||||||
|
8. Unit tests for all tools (mock engines, no AI calls)
|
||||||
|
|
||||||
|
### Phase 2: Providers + Chat + Tasks (1-2 sessions)
|
||||||
|
9. Create `ai/providers.ts` — `ProviderRegistry` with OpenCode gateway + Mistral direct
|
||||||
|
10. Extend `SecureKeyStore` for multi-provider keys (`provider_${id}_api_key`)
|
||||||
|
11. Create `ai/chat.ts` — `ChatService` with `streamText()`
|
||||||
|
12. Create `ai/tasks.ts` — `OneShotTasks` with `generateText()`
|
||||||
|
13. Update IPC handlers: generic provider management, wire to new modules
|
||||||
|
14. Integration tests
|
||||||
|
|
||||||
|
### Phase 3: Delete + ship (1 session)
|
||||||
|
15. Delete `OpenCodeManager.ts` (2,745 lines)
|
||||||
|
16. Delete `streaming.ts` (621 lines)
|
||||||
|
17. Delete old MCPServer duplication
|
||||||
|
18. Update all tests, full build pass
|
||||||
|
19. Smoke test: chat conversation end-to-end, taxonomy analysis, image analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. ~~**Zen baseURL paths**~~ — **RESOLVED**: `@ai-sdk/anthropic` appends `/messages`, `@ai-sdk/openai.chat()` appends `/chat/completions`. Verified from SDK source code and mock tests.
|
||||||
|
2. **Model listing** — Zen model list endpoint vs AI SDK model discovery. Likely keep thin HTTP for now.
|
||||||
|
3. **DB message format** — Current `chatMessages` schema stores role/content/toolCallId/toolCalls. AI SDK `response.messages` may use a richer format. Evaluate whether to migrate schema or adapt at persistence layer.
|
||||||
142
package-lock.json
generated
142
package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3.0.50",
|
||||||
|
"@ai-sdk/mistral": "^3.0.21",
|
||||||
|
"@ai-sdk/openai": "^3.0.37",
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
"@floating-ui/dom": "^1.7.5",
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@highlightjs/cdn-assets": "^11.11.1",
|
"@highlightjs/cdn-assets": "^11.11.1",
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
|
"ai": "^6.0.105",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"d3-cloud": "^1.2.8",
|
"d3-cloud": "^1.2.8",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -104,6 +108,100 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@ai-sdk/anthropic": {
|
||||||
|
"version": "3.0.50",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.50.tgz",
|
||||||
|
"integrity": "sha512-BkCUgGTp/iZJuuFBF1wv7GGnrEJg7X7hqbaa+/t4HTBt9dZn3e6NFn5NhPUvo2p5SreUeHEl0As0r2uaVn3K9Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/gateway": {
|
||||||
|
"version": "3.0.59",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.59.tgz",
|
||||||
|
"integrity": "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.16",
|
||||||
|
"@vercel/oidc": "3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/mistral": {
|
||||||
|
"version": "3.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.21.tgz",
|
||||||
|
"integrity": "sha512-GhLRyfXghnKpt51t8ELU3cXwbCUcGOxZFUQFUZvNBt+y298PlOhaAK6pQtihfpNRGUycrdNotKwVajJm0a6uwg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/openai": {
|
||||||
|
"version": "3.0.37",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.37.tgz",
|
||||||
|
"integrity": "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider-utils": {
|
||||||
|
"version": "4.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.16.tgz",
|
||||||
|
"integrity": "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"eventsource-parser": "^3.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
|
||||||
@@ -4765,6 +4863,16 @@
|
|||||||
"url": "https://github.com/sponsors/ocavue"
|
"url": "https://github.com/sponsors/ocavue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@opentelemetry/api": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oven/bun-darwin-aarch64": {
|
"node_modules/@oven/bun-darwin-aarch64": {
|
||||||
"version": "1.3.10",
|
"version": "1.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
|
||||||
@@ -5318,7 +5426,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@szmarczak/http-timer": {
|
"node_modules/@szmarczak/http-timer": {
|
||||||
@@ -6008,6 +6115,15 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vercel/oidc": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||||
@@ -6416,6 +6532,24 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ai": {
|
||||||
|
"version": "6.0.105",
|
||||||
|
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.105.tgz",
|
||||||
|
"integrity": "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/gateway": "3.0.59",
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.16",
|
||||||
|
"@opentelemetry/api": "1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
@@ -10884,6 +11018,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
"wait-on": "^9.0.3"
|
"wait-on": "^9.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3.0.50",
|
||||||
|
"@ai-sdk/mistral": "^3.0.21",
|
||||||
|
"@ai-sdk/openai": "^3.0.37",
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
"@floating-ui/dom": "^1.7.5",
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@highlightjs/cdn-assets": "^11.11.1",
|
"@highlightjs/cdn-assets": "^11.11.1",
|
||||||
@@ -89,6 +92,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
|
"ai": "^6.0.105",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"d3-cloud": "^1.2.8",
|
"d3-cloud": "^1.2.8",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -163,7 +167,9 @@
|
|||||||
{
|
{
|
||||||
"from": "src/main/engine/assets",
|
"from": "src/main/engine/assets",
|
||||||
"to": "assets",
|
"to": "assets",
|
||||||
"filter": ["*.css"]
|
"filter": [
|
||||||
|
"*.css"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"protocols": [
|
"protocols": [
|
||||||
|
|||||||
769
tests/engine/ai-sdk-phase0.test.ts
Normal file
769
tests/engine/ai-sdk-phase0.test.ts
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
/**
|
||||||
|
* Phase 0: AI SDK Integration Validation
|
||||||
|
*
|
||||||
|
* Validates that AI SDK works in our Node.js/Electron-compatible runtime:
|
||||||
|
* - Provider imports and instantiation work
|
||||||
|
* - createProviderRegistry resolves providerId:modelId correctly
|
||||||
|
* - customProvider with fallback routes gateway models to correct SDK type
|
||||||
|
* - baseURL override produces correct request URLs
|
||||||
|
* - generateText and streamText produce correct shaped request bodies
|
||||||
|
* - No static model-to-provider maps needed — provider prefix is the routing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
generateText,
|
||||||
|
streamText,
|
||||||
|
tool,
|
||||||
|
createProviderRegistry,
|
||||||
|
customProvider,
|
||||||
|
} from 'ai';
|
||||||
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
import { createMistral } from '@ai-sdk/mistral';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers: capture what the SDK would send without hitting a real API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock fetch that captures the request details and returns a
|
||||||
|
* provider-appropriate response so the SDK doesn't throw.
|
||||||
|
*/
|
||||||
|
function createCapturingFetch(providerType: 'anthropic' | 'openai') {
|
||||||
|
const captured: { url: string; body: any; headers: any }[] = [];
|
||||||
|
|
||||||
|
const mockFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input.toString();
|
||||||
|
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
||||||
|
const headers = init?.headers;
|
||||||
|
captured.push({ url, body, headers });
|
||||||
|
|
||||||
|
// Return a minimal valid response for the provider type
|
||||||
|
if (providerType === 'anthropic') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'msg_test',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'Hello from mock' }],
|
||||||
|
model: body?.model ?? 'claude-sonnet-4-5',
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
usage: { input_tokens: 10, output_tokens: 5 },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI / Mistral (OpenAI-compatible)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'chatcmpl-test',
|
||||||
|
object: 'chat.completion',
|
||||||
|
model: body?.model ?? 'gpt-4.1',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'Hello from mock' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { mockFetch, captured };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Provider imports and instantiation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Provider Imports', () => {
|
||||||
|
it('creates Anthropic provider with baseURL override', () => {
|
||||||
|
const provider = createAnthropic({
|
||||||
|
baseURL: 'https://opencode.ai/zen/v1',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
});
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(typeof provider).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates OpenAI provider with baseURL override', () => {
|
||||||
|
const provider = createOpenAI({
|
||||||
|
baseURL: 'https://opencode.ai/zen/v1',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
});
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(typeof provider).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates Mistral provider with defaults', () => {
|
||||||
|
const provider = createMistral({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
});
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(typeof provider).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a LanguageModel from Anthropic provider', () => {
|
||||||
|
const provider = createAnthropic({ apiKey: 'test-key' });
|
||||||
|
const model = provider('claude-sonnet-4-5');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect(model.modelId).toBe('claude-sonnet-4-5');
|
||||||
|
expect(model.provider).toContain('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a LanguageModel from OpenAI provider', () => {
|
||||||
|
const provider = createOpenAI({ apiKey: 'test-key' });
|
||||||
|
const model = provider('gpt-4.1');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect(model.modelId).toBe('gpt-4.1');
|
||||||
|
expect(model.provider).toContain('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a LanguageModel from Mistral provider', () => {
|
||||||
|
const provider = createMistral({ apiKey: 'test-key' });
|
||||||
|
const model = provider('mistral-large-latest');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect(model.modelId).toBe('mistral-large-latest');
|
||||||
|
expect(model.provider).toContain('mistral');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Provider Registry — providerId:modelId routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Provider Registry', () => {
|
||||||
|
it('resolves models via providerId:modelId prefix', () => {
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
anthropic: createAnthropic({ apiKey: 'k1' }),
|
||||||
|
openai: createOpenAI({ apiKey: 'k2' }),
|
||||||
|
mistral: createMistral({ apiKey: 'k3' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const claude = registry.languageModel('anthropic:claude-sonnet-4-5');
|
||||||
|
expect(claude.modelId).toBe('claude-sonnet-4-5');
|
||||||
|
expect(claude.provider).toContain('anthropic');
|
||||||
|
|
||||||
|
const gpt = registry.languageModel('openai:gpt-4.1');
|
||||||
|
expect(gpt.modelId).toBe('gpt-4.1');
|
||||||
|
expect(gpt.provider).toContain('openai');
|
||||||
|
|
||||||
|
const mistral = registry.languageModel('mistral:mistral-large-latest');
|
||||||
|
expect(mistral.modelId).toBe('mistral-large-latest');
|
||||||
|
expect(mistral.provider).toContain('mistral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on unknown provider prefix', () => {
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
anthropic: createAnthropic({ apiKey: 'k1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => registry.languageModel('unknown:model-id')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Gateway pattern — OpenCode Zen as custom provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Gateway Custom Provider', () => {
|
||||||
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an OpenCode gateway provider that routes models to the correct
|
||||||
|
* SDK type based on a minimal rule:
|
||||||
|
* - claude* → Anthropic Messages API
|
||||||
|
* - everything else → OpenAI Chat Completions API
|
||||||
|
*
|
||||||
|
* No static model list needed. The rule is: "Anthropic models use the
|
||||||
|
* Anthropic API, all others use OpenAI-compatible." This matches
|
||||||
|
* reality — only Anthropic has a separate Messages API.
|
||||||
|
*
|
||||||
|
* IMPORTANT: OpenAI SDK v6 defaults to the Responses API (/responses).
|
||||||
|
* OpenCode Zen (and most third-party gateways) only support Chat
|
||||||
|
* Completions (/chat/completions). Use provider.chat(modelId) to get
|
||||||
|
* a Chat Completions model instead of the default Responses model.
|
||||||
|
*/
|
||||||
|
function createOpenCodeGateway(apiKey: string, fetchImpl?: typeof fetch) {
|
||||||
|
const anthropicProvider = createAnthropic({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey,
|
||||||
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
||||||
|
});
|
||||||
|
const openaiProvider = createOpenAI({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey,
|
||||||
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return customProvider({
|
||||||
|
// No explicit model list — use fallbackProvider with routing logic
|
||||||
|
languageModels: {},
|
||||||
|
fallbackProvider: {
|
||||||
|
languageModel: (modelId: string) => {
|
||||||
|
// Minimal routing: only Claude uses Anthropic Messages API
|
||||||
|
if (modelId.startsWith('claude')) {
|
||||||
|
return anthropicProvider(modelId);
|
||||||
|
}
|
||||||
|
// Use .chat() for Chat Completions — Zen doesn't support Responses API
|
||||||
|
return openaiProvider.chat(modelId);
|
||||||
|
},
|
||||||
|
embeddingModel: () => {
|
||||||
|
throw new Error('Embeddings not supported via OpenCode gateway');
|
||||||
|
},
|
||||||
|
imageModel: () => {
|
||||||
|
throw new Error('Image models not supported via OpenCode gateway');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('routes claude models to Anthropic provider', () => {
|
||||||
|
const gateway = createOpenCodeGateway('test-key');
|
||||||
|
const model = gateway.languageModel('claude-sonnet-4-5');
|
||||||
|
expect(model.provider).toContain('anthropic');
|
||||||
|
expect(model.modelId).toBe('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes gpt models to OpenAI provider', () => {
|
||||||
|
const gateway = createOpenCodeGateway('test-key');
|
||||||
|
const model = gateway.languageModel('gpt-4.1');
|
||||||
|
expect(model.provider).toContain('openai');
|
||||||
|
expect(model.modelId).toBe('gpt-4.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes gemini models to OpenAI provider (OpenAI-compatible via Zen)', () => {
|
||||||
|
const gateway = createOpenCodeGateway('test-key');
|
||||||
|
const model = gateway.languageModel('gemini-2.5-pro');
|
||||||
|
expect(model.provider).toContain('openai');
|
||||||
|
expect(model.modelId).toBe('gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes o3/o4 models to OpenAI provider', () => {
|
||||||
|
const gateway = createOpenCodeGateway('test-key');
|
||||||
|
expect(gateway.languageModel('o3-mini').provider).toContain('openai');
|
||||||
|
expect(gateway.languageModel('o4-mini').provider).toContain('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('integrates with provider registry', () => {
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
opencode: createOpenCodeGateway('oc-key'),
|
||||||
|
mistral: createMistral({ apiKey: 'mi-key' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude through OpenCode gateway
|
||||||
|
const claude = registry.languageModel('opencode:claude-sonnet-4-5');
|
||||||
|
expect(claude.provider).toContain('anthropic');
|
||||||
|
|
||||||
|
// GPT through OpenCode gateway
|
||||||
|
const gpt = registry.languageModel('opencode:gpt-4.1');
|
||||||
|
expect(gpt.provider).toContain('openai');
|
||||||
|
|
||||||
|
// Mistral direct
|
||||||
|
const mistral = registry.languageModel('mistral:mistral-large-latest');
|
||||||
|
expect(mistral.provider).toContain('mistral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports adding direct providers alongside gateway', () => {
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
opencode: createOpenCodeGateway('oc-key'),
|
||||||
|
anthropic: createAnthropic({ apiKey: 'direct-anthropic-key' }),
|
||||||
|
openai: createOpenAI({ apiKey: 'direct-openai-key' }),
|
||||||
|
mistral: createMistral({ apiKey: 'mi-key' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same model, different providers (gateway vs direct)
|
||||||
|
const viaGateway = registry.languageModel('opencode:claude-sonnet-4-5');
|
||||||
|
const viaDirect = registry.languageModel('anthropic:claude-sonnet-4-5');
|
||||||
|
|
||||||
|
// Both are Anthropic SDK models but with different baseURLs
|
||||||
|
expect(viaGateway.provider).toContain('anthropic');
|
||||||
|
expect(viaDirect.provider).toContain('anthropic');
|
||||||
|
expect(viaGateway.modelId).toBe('claude-sonnet-4-5');
|
||||||
|
expect(viaDirect.modelId).toBe('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. BaseURL request path verification — Zen gateway compatibility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Zen BaseURL Request Paths', () => {
|
||||||
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
||||||
|
|
||||||
|
it('Anthropic provider appends /messages to baseURL', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const provider = createAnthropic({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: provider('claude-sonnet-4-5'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured.length).toBeGreaterThan(0);
|
||||||
|
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/messages');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OpenAI provider.chat() appends /chat/completions to baseURL', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
||||||
|
|
||||||
|
const provider = createOpenAI({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use .chat() — the default provider() uses Responses API (/responses)
|
||||||
|
// which Zen and most third-party gateways don't support
|
||||||
|
await generateText({
|
||||||
|
model: provider.chat('gpt-4.1'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured.length).toBeGreaterThan(0);
|
||||||
|
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/chat/completions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anthropic provider sends x-api-key header', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const provider = createAnthropic({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey: 'my-zen-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: provider('claude-sonnet-4-5'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = captured[0].headers;
|
||||||
|
// Headers could be a Headers object or plain object
|
||||||
|
const apiKey =
|
||||||
|
headers instanceof Headers
|
||||||
|
? headers.get('x-api-key')
|
||||||
|
: headers?.['x-api-key'];
|
||||||
|
expect(apiKey).toBe('my-zen-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OpenAI provider sends Authorization Bearer header', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
||||||
|
|
||||||
|
const provider = createOpenAI({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey: 'my-zen-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: provider.chat('gpt-4.1'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = captured[0].headers;
|
||||||
|
const auth =
|
||||||
|
headers instanceof Headers
|
||||||
|
? headers.get('authorization')
|
||||||
|
: headers?.['Authorization'] ?? headers?.['authorization'];
|
||||||
|
expect(auth).toContain('Bearer my-zen-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Tool definitions work with AI SDK tool() + Zod
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Tool Definitions', () => {
|
||||||
|
it('defines a tool with Zod schema and execute function', () => {
|
||||||
|
const checkTerm = tool({
|
||||||
|
description: 'Check whether a term exists as a category, tag, or both',
|
||||||
|
inputSchema: z.object({
|
||||||
|
term: z.string().describe('The term to look up'),
|
||||||
|
}),
|
||||||
|
execute: async ({ term }) => {
|
||||||
|
return { term, found: true, type: 'category' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkTerm).toBeDefined();
|
||||||
|
expect(checkTerm.description).toBe(
|
||||||
|
'Check whether a term exists as a category, tag, or both',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines a tool with toModelOutput for multimodal results', () => {
|
||||||
|
const viewImage = tool({
|
||||||
|
description: 'View an image',
|
||||||
|
inputSchema: z.object({
|
||||||
|
media_id: z.number(),
|
||||||
|
size: z.enum(['small', 'medium', 'large']).default('medium'),
|
||||||
|
}),
|
||||||
|
execute: async ({ media_id }) => {
|
||||||
|
return {
|
||||||
|
base64: 'iVBORw0KGgo...',
|
||||||
|
mediaType: 'image/png' as const,
|
||||||
|
caption: `Image #${media_id}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toModelOutput: ({ output }) => ({
|
||||||
|
type: 'content' as const,
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
type: 'image' as const,
|
||||||
|
data: output.base64,
|
||||||
|
mediaType: output.mediaType,
|
||||||
|
},
|
||||||
|
{ type: 'text' as const, text: output.caption },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(viewImage).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends tools in generateText request body', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const provider = createAnthropic({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: provider('claude-sonnet-4-5'),
|
||||||
|
prompt: 'Check the term "javascript"',
|
||||||
|
tools: {
|
||||||
|
check_term: tool({
|
||||||
|
description: 'Check a term',
|
||||||
|
inputSchema: z.object({ term: z.string() }),
|
||||||
|
execute: async ({ term }) => ({ found: true, term }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured[0].body.tools).toBeDefined();
|
||||||
|
expect(captured[0].body.tools.length).toBeGreaterThan(0);
|
||||||
|
expect(captured[0].body.tools[0].name).toBe('check_term');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 6. Anthropic-specific providerOptions (cache control, etc.)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Anthropic Provider Options', () => {
|
||||||
|
it('sends cache control on system message', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const provider = createAnthropic({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: provider('claude-sonnet-4-5'),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: 'You are a blog editor.',
|
||||||
|
providerOptions: {
|
||||||
|
anthropic: { cacheControl: { type: 'ephemeral' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
],
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anthropic SDK sends system as top-level field or within messages
|
||||||
|
// The key assertion is that the request succeeds with providerOptions
|
||||||
|
expect(captured.length).toBeGreaterThan(0);
|
||||||
|
const body = captured[0].body;
|
||||||
|
// System with cache_control should appear in the request
|
||||||
|
expect(body.system).toBeDefined();
|
||||||
|
const systemBlock = Array.isArray(body.system) ? body.system : [body.system];
|
||||||
|
const hasCacheControl = systemBlock.some(
|
||||||
|
(s: any) => s.cache_control?.type === 'ephemeral',
|
||||||
|
);
|
||||||
|
expect(hasCacheControl).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends cache control on tools', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const provider = createAnthropic({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: provider('claude-sonnet-4-5'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
tools: {
|
||||||
|
check_term: tool({
|
||||||
|
description: 'Check a term',
|
||||||
|
inputSchema: z.object({ term: z.string() }),
|
||||||
|
execute: async ({ term }) => ({ found: true, term }),
|
||||||
|
providerOptions: {
|
||||||
|
anthropic: { cacheControl: { type: 'ephemeral' } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured[0].body.tools).toBeDefined();
|
||||||
|
const toolDef = captured[0].body.tools[0];
|
||||||
|
expect(toolDef.cache_control?.type).toBe('ephemeral');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 7. generateText produces correct model + usage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — generateText Response', () => {
|
||||||
|
it('returns text and usage from Anthropic provider', async () => {
|
||||||
|
const { mockFetch } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const provider = createAnthropic({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: provider('claude-sonnet-4-5'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.text).toBe('Hello from mock');
|
||||||
|
expect(result.usage.inputTokens).toBe(10);
|
||||||
|
expect(result.usage.outputTokens).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns text and usage from OpenAI provider (chat mode)', async () => {
|
||||||
|
const { mockFetch } = createCapturingFetch('openai');
|
||||||
|
|
||||||
|
const provider = createOpenAI({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use .chat() for Chat Completions format
|
||||||
|
const result = await generateText({
|
||||||
|
model: provider.chat('gpt-4.1'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.text).toBe('Hello from mock');
|
||||||
|
expect(result.usage.inputTokens).toBe(10);
|
||||||
|
expect(result.usage.outputTokens).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns text via provider registry resolution', async () => {
|
||||||
|
const { mockFetch } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
anthropic: createAnthropic({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: mockFetch,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: registry.languageModel('anthropic:claude-sonnet-4-5'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.text).toBe('Hello from mock');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 8. Gateway end-to-end: registry → gateway → correct provider → correct URL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Gateway End-to-End', () => {
|
||||||
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
||||||
|
|
||||||
|
function createOpenCodeGateway(apiKey: string, fetchImpl: typeof fetch) {
|
||||||
|
const anthropicProvider = createAnthropic({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey,
|
||||||
|
fetch: fetchImpl,
|
||||||
|
});
|
||||||
|
const openaiProvider = createOpenAI({
|
||||||
|
baseURL: ZEN_BASE_URL,
|
||||||
|
apiKey,
|
||||||
|
fetch: fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return customProvider({
|
||||||
|
languageModels: {},
|
||||||
|
fallbackProvider: {
|
||||||
|
languageModel: (modelId: string) => {
|
||||||
|
if (modelId.startsWith('claude')) {
|
||||||
|
return anthropicProvider(modelId);
|
||||||
|
}
|
||||||
|
// Must use .chat() for Chat Completions API (Zen gateway compatible)
|
||||||
|
return openaiProvider.chat(modelId);
|
||||||
|
},
|
||||||
|
embeddingModel: () => {
|
||||||
|
throw new Error('Not supported');
|
||||||
|
},
|
||||||
|
imageModel: () => {
|
||||||
|
throw new Error('Not supported');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Claude via gateway hits Zen Anthropic endpoint', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
||||||
|
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
opencode: createOpenCodeGateway('oc-key', mockFetch),
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: registry.languageModel('opencode:claude-sonnet-4-5'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/messages');
|
||||||
|
expect(captured[0].body.model).toBe('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GPT via gateway hits Zen OpenAI endpoint', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
||||||
|
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
opencode: createOpenCodeGateway('oc-key', mockFetch),
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: registry.languageModel('opencode:gpt-4.1'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured[0].url).toBe(
|
||||||
|
'https://opencode.ai/zen/v1/chat/completions',
|
||||||
|
);
|
||||||
|
expect(captured[0].body.model).toBe('gpt-4.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gemini via gateway hits Zen OpenAI endpoint', async () => {
|
||||||
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
||||||
|
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
opencode: createOpenCodeGateway('oc-key', mockFetch),
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: registry.languageModel('opencode:gemini-2.5-pro'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured[0].url).toBe(
|
||||||
|
'https://opencode.ai/zen/v1/chat/completions',
|
||||||
|
);
|
||||||
|
expect(captured[0].body.model).toBe('gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Mistral via direct provider (not gateway)', async () => {
|
||||||
|
const { mockFetch: anthropicFetch } = createCapturingFetch('anthropic');
|
||||||
|
const { mockFetch: mistralFetch, captured: mistralCaptured } =
|
||||||
|
createCapturingFetch('openai'); // Mistral uses OpenAI-compat format
|
||||||
|
|
||||||
|
const registry = createProviderRegistry({
|
||||||
|
opencode: createOpenCodeGateway('oc-key', anthropicFetch),
|
||||||
|
mistral: createMistral({
|
||||||
|
apiKey: 'mi-key',
|
||||||
|
fetch: mistralFetch,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateText({
|
||||||
|
model: registry.languageModel('mistral:mistral-large-latest'),
|
||||||
|
prompt: 'Hello',
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mistralCaptured[0].url).toBe(
|
||||||
|
'https://api.mistral.ai/v1/chat/completions',
|
||||||
|
);
|
||||||
|
expect(mistralCaptured[0].body.model).toBe('mistral-large-latest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 9. No static model map: the routing rule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AI SDK Phase 0 — Model Routing Without Static Maps', () => {
|
||||||
|
it('the only routing rule is: claude* → Anthropic API, else → OpenAI API', () => {
|
||||||
|
// This test documents the design decision. There is no model registry or
|
||||||
|
// regex map. The gateway provider uses a single if/else to pick the SDK.
|
||||||
|
// This is correct because only Anthropic has a separate Messages API;
|
||||||
|
// every other model family (OpenAI, Google, Cohere, etc.) uses
|
||||||
|
// OpenAI-compatible Chat Completions endpoints.
|
||||||
|
|
||||||
|
const anthropicModels = [
|
||||||
|
'claude-sonnet-4-5',
|
||||||
|
'claude-opus-4-1',
|
||||||
|
'claude-haiku-4-5',
|
||||||
|
'claude-3-7-sonnet-20250219',
|
||||||
|
];
|
||||||
|
|
||||||
|
const openaiCompatModels = [
|
||||||
|
'gpt-4.1',
|
||||||
|
'gpt-4o',
|
||||||
|
'o3-mini',
|
||||||
|
'o4-mini',
|
||||||
|
'gemini-2.5-pro',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
'command-r-plus',
|
||||||
|
'some-future-model-xyz',
|
||||||
|
];
|
||||||
|
|
||||||
|
function routeModel(modelId: string): 'anthropic' | 'openai-compat' {
|
||||||
|
return modelId.startsWith('claude') ? 'anthropic' : 'openai-compat';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of anthropicModels) {
|
||||||
|
expect(routeModel(m)).toBe('anthropic');
|
||||||
|
}
|
||||||
|
for (const m of openaiCompatModels) {
|
||||||
|
expect(routeModel(m)).toBe('openai-compat');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user