diff --git a/electron/assets/providers/catalog.json b/electron/assets/providers/catalog.json index 60f07381..8c60ccb1 100644 --- a/electron/assets/providers/catalog.json +++ b/electron/assets/providers/catalog.json @@ -6613,6 +6613,207 @@ } ] }, + { + "id": "meta", + "displayName": "Meta", + "supportedAuthMethods": [ + "api-key", + "environment" + ], + "supportedProtocols": [ + "openai-responses" + ], + "allowsCustomModels": true, + "lifecycle": "active", + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + }, + "models": [ + { + "id": "muse-spark-1.1", + "displayName": "Muse Spark 1.1", + "protocol": "openai-responses", + "capabilities": { + "inputModalities": [ + "text", + "image", + "pdf", + "video" + ], + "outputModalities": [ + "text" + ], + "tools": true, + "reasoning": true + }, + "reasoningLevels": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "reasoningDefault": "medium", + "limits": { + "contextTokens": 1000000, + "outputTokens": 32000 + }, + "lifecycle": "active", + "pricing": { + "currency": "USD", + "effectiveAt": "2026-08-12T03:40:23.755Z", + "rates": { + "input": { + "amount": "1.25", + "per": 1000000, + "unit": "tokens" + }, + "output": { + "amount": "4.25", + "per": 1000000, + "unit": "tokens" + }, + "cacheRead": { + "amount": "0.15", + "per": 1000000, + "unit": "tokens" + } + }, + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + } + }, + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + } + }, + { + "id": "muse-spark-1.2", + "displayName": "Muse Spark 1.2", + "protocol": "openai-responses", + "capabilities": { + "inputModalities": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "outputModalities": [ + "text" + ], + "tools": true, + "reasoning": true + }, + "reasoningLevels": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "reasoningDefault": "medium", + "limits": { + "contextTokens": 1048576, + "outputTokens": 131072 + }, + "lifecycle": "active", + "pricing": { + "currency": "USD", + "effectiveAt": "2026-08-12T03:40:23.755Z", + "rates": { + "input": { + "amount": "1.25", + "per": 1000000, + "unit": "tokens" + }, + "output": { + "amount": "4.25", + "per": 1000000, + "unit": "tokens" + }, + "cacheRead": { + "amount": "0.15", + "per": 1000000, + "unit": "tokens" + } + }, + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + } + }, + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + } + }, + { + "id": "muse-spark-1.2-contributor", + "displayName": "Muse Spark 1.2 Contributor", + "protocol": "openai-responses", + "capabilities": { + "inputModalities": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "outputModalities": [ + "text" + ], + "tools": true, + "reasoning": true + }, + "reasoningLevels": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "reasoningDefault": "medium", + "limits": { + "contextTokens": 1048576, + "outputTokens": 131072 + }, + "lifecycle": "active", + "pricing": { + "currency": "USD", + "effectiveAt": "2026-08-12T03:40:23.755Z", + "rates": { + "input": { + "amount": "0.1", + "per": 1000000, + "unit": "tokens" + }, + "output": { + "amount": "0.2", + "per": 1000000, + "unit": "tokens" + }, + "cacheRead": { + "amount": "0.002", + "per": 1000000, + "unit": "tokens" + } + }, + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + } + }, + "provenance": { + "source": "models.dev", + "observedAt": "2026-08-12T03:40:23.755Z" + } + } + ] + }, { "id": "generic-openai-compatible", "displayName": "Generic OpenAI-compatible", diff --git a/electron/scripts/provider-catalog/seed-models-dev.ts b/electron/scripts/provider-catalog/seed-models-dev.ts index ac92c0ed..2b341593 100644 --- a/electron/scripts/provider-catalog/seed-models-dev.ts +++ b/electron/scripts/provider-catalog/seed-models-dev.ts @@ -18,6 +18,7 @@ const PROVIDERS = [ { sourceId: 'opencode-go', id: 'opencode-go', protocol: 'openai-compatible', auth: ['api-key', 'environment'] }, { sourceId: 'lilac', id: 'lilac', protocol: 'openai-compatible', auth: ['api-key', 'environment'] }, { sourceId: 'neuralwatt', id: 'neuralwatt', protocol: 'openai-compatible', auth: ['api-key', 'environment'] }, + { sourceId: 'meta', id: 'meta', protocol: 'openai-responses', auth: ['api-key', 'environment'] }, ]; /** Generic providers are Orchid-owned definitions, not models.dev providers. */ diff --git a/electron/src/main/agents/subagent-runner.ts b/electron/src/main/agents/subagent-runner.ts index f578c11e..250a4513 100644 --- a/electron/src/main/agents/subagent-runner.ts +++ b/electron/src/main/agents/subagent-runner.ts @@ -14,7 +14,7 @@ import type { ThinkingReplayContext } from '../llm/history'; import type { CacheFacet, ThinkingPolicy } from '../../shared/types/provider-facets'; import { resolveSubagentTier } from '../providers/facets/tiers'; import { assembleFacetProviderOptions } from '../providers/facets/turn-options'; -import type { ModelSelection } from '../../shared/types/provider'; +import type { ModelSelection, ProviderProtocol } from '../../shared/types/provider'; import { streamChat, type StreamEvent } from '../llm/orchestrator'; import { resolveSubagentEffort } from '../llm/reasoning-effort'; import { getConfig } from '../config/loader'; @@ -281,6 +281,7 @@ export function createSubagentStreamRunner(): SubagentStreamRunner { thinkingReplay: { policy: thinkingPolicy ?? DEFAULT_THINKING_POLICY, selection: { providerId: providerSnapshot.providerId, modelId: selection.modelId }, + protocol: providerSnapshot.protocol as ProviderProtocol, } satisfies ThinkingReplayContext, cachePlacement: cacheFacet ? { facet: cacheFacet, ttl: cacheTtl, sessionKey: cacheSessionKey } diff --git a/electron/src/main/ipc/chat/send.ts b/electron/src/main/ipc/chat/send.ts index 697b50b2..46cea9b4 100644 --- a/electron/src/main/ipc/chat/send.ts +++ b/electron/src/main/ipc/chat/send.ts @@ -22,6 +22,7 @@ import { } from '../../providers/facets/thinking'; import type { ThinkingReplayContext } from '../../llm/history'; import type { CacheFacet, ThinkingPolicy } from '../../../shared/types/provider-facets'; +import type { ProviderProtocol } from '../../../shared/types/provider'; import { resolveMainAgentTier } from '../../providers/facets/tiers'; import { assembleFacetProviderOptions } from '../../providers/facets/turn-options'; import { getSessionManager } from '../../session/singleton'; @@ -176,6 +177,7 @@ export async function startChatTurn( const thinkingReplay: ThinkingReplayContext = { policy: thinkingPolicy ?? DEFAULT_THINKING_POLICY, selection: { providerId: providerSnapshot.providerId, modelId: turnSelection.modelId }, + protocol: providerSnapshot.protocol as ProviderProtocol, }; const agent = agents.find((candidate) => candidate.name === 'general') ?? agents[0] ?? { name: 'general', type: 'subagent' as const, tier: 'bloom' as const, diff --git a/electron/src/main/llm/history.ts b/electron/src/main/llm/history.ts index ba499d43..b6734991 100644 --- a/electron/src/main/llm/history.ts +++ b/electron/src/main/llm/history.ts @@ -26,6 +26,7 @@ import type { } from '../../shared/types/message'; import { MessageType, MessageRole, messageToApiFormat } from '../../shared/types/message'; import type { ThinkingPolicy } from '../../shared/types/provider-facets'; +import type { ProviderProtocol } from '../../shared/types/provider'; import { buildThinkingProviderOptions, decideThinkingReplay, @@ -36,6 +37,12 @@ import { export interface ThinkingReplayContext { readonly policy: ThinkingPolicy; readonly selection: ThinkingReplayIdentity; + /** + * Frozen model protocol. Responses-protocol models enforce reasoning-item + * ordering at replay (a reasoning item must be followed by an assistant + * message or function_call, or the provider rejects the input). + */ + readonly protocol?: ProviderProtocol; } function thinkingReplayPart( diff --git a/electron/src/main/llm/model-messages.ts b/electron/src/main/llm/model-messages.ts index ce8a74cf..2a902f3c 100644 --- a/electron/src/main/llm/model-messages.ts +++ b/electron/src/main/llm/model-messages.ts @@ -2,7 +2,18 @@ * Model message conversion — OpenAI-shaped replay records to AI SDK input. * * `history.ts` owns replay/pairing semantics. This adapter owns only the - * provider-neutral shape conversion required by AI SDK's `streamText`. + * provider-neutral shape conversion required by AI SDK's `streamText`, plus + * two Responses-protocol replay rules: + * + * - Commentary phase: assistant text that precedes tool calls in the same + * message is intermediate commentary, so it replays with + * `openai.phase: "commentary"` for the Responses adapter (Meta rejects a + * plain final-answer message immediately before a function_call). + * - Reasoning ordering: a replayed reasoning item must be followed by an + * assistant message or a function_call before the next user/system/developer + * message (Meta returns HTTP 400 otherwise). Reasoning-only turns — a + * cancelled turn or a text-less artifact — get a minimal assistant message + * after the reasoning item, the workaround the provider documents. */ import type { AssistantContent, ModelMessage } from 'ai'; import { @@ -23,7 +34,10 @@ function toTextOnlyContent(content: ApiMessage['content']): string { * System messages deliberately do not appear in the returned array: callers * pass the composed system prompt through `streamText`'s `system` parameter. */ -export function toModelMessages(historyMessages: readonly ApiMessage[]): ModelMessage[] { +export function toModelMessages( + historyMessages: readonly ApiMessage[], + options: { readonly responsesReplay?: boolean } = {}, +): ModelMessage[] { const modelMessages: ModelMessage[] = []; for (const message of historyMessages) { @@ -32,6 +46,7 @@ export function toModelMessages(historyMessages: readonly ApiMessage[]): ModelMe } if (message.role === MessageRole.ASSISTANT) { + const hasToolCalls = (message.tool_calls?.length ?? 0) > 0; const contentArray = Array.isArray(message.content) ? message.content.map((part) => { if (part.type === 'reasoning') { @@ -43,7 +58,15 @@ export function toModelMessages(historyMessages: readonly ApiMessage[]): ModelMe ...(part.providerOptions ? { providerOptions: part.providerOptions } : {}), }; } - return { type: 'text' as const, text: part.text }; + // Text emitted before tool calls in the same message is + // intermediate commentary (Meta requires phase: "commentary" on + // such replayed messages; a plain final answer 400s). + const commentary = options.responsesReplay && hasToolCalls; + return { + type: 'text' as const, + text: part.text, + ...(commentary ? { providerOptions: { openai: { phase: 'commentary' } } } : {}), + }; }) : message.content ? [{ type: 'text' as const, text: message.content }] @@ -106,5 +129,34 @@ export function toModelMessages(historyMessages: readonly ApiMessage[]): ModelMe } } + if (options.responsesReplay) { + insertMinimalAssistantAfterReasoningOnly(modelMessages); + } + return modelMessages; } + +/** + * Responses input validation requires every reasoning item to be followed by + * an assistant message or a function_call before the next user/system/developer + * message. Orchid replays each THINKING message as its own assistant reasoning + * message, so a reasoning-only turn (cancelled mid-thought, or a text-less + * opaque artifact) would leave `reasoning` directly before a user message and + * be rejected. The provider-documented workaround is a minimal assistant + * message inserted after the reasoning item. + */ +function insertMinimalAssistantAfterReasoningOnly(messages: ModelMessage[]): void { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const parts = Array.isArray(message.content) ? message.content : []; + const hasReasoning = parts.some((part) => part.type === 'reasoning'); + if (!hasReasoning) continue; + const next = messages[index + 1]; + if (next?.role === 'assistant') continue; + messages.splice(index + 1, 0, { + role: 'assistant', + content: [{ type: 'text', text: '' }], + }); + index += 1; + } +} diff --git a/electron/src/main/llm/orchestrator.ts b/electron/src/main/llm/orchestrator.ts index c50a84b6..bdcc62d6 100644 --- a/electron/src/main/llm/orchestrator.ts +++ b/electron/src/main/llm/orchestrator.ts @@ -201,7 +201,9 @@ export async function* streamChat(params: StreamChatParams): AsyncGenerator { + const { createOpenAI } = await importESM('@ai-sdk/openai'); + return createOpenAI({ + name: 'meta', + baseURL: META_API_ORIGIN, + apiKey: input.apiKey, + }).responses(input.modelId); +} + +export function createMetaProviderDriver(): ProviderDriver { + return { + id: 'meta', + supportedAuthMethods: ['api-key', 'environment'], + supportedProtocols: ['openai-responses'], + allowsCustomEndpoint: false, + origin: META_API_ORIGIN, + createLanguageModel: async ({ model, credential }) => createMetaLanguageModel({ + modelId: model.id, + apiKey: apiKeyForDriver(credential), + }), + thinkingPolicy: (_model: EffectiveModel): ThinkingPolicy => META_THINKING_POLICY, + buildReasoningOptions: (effort: string | number): ReasoningProviderOptions | undefined => { + if (typeof effort !== 'string') return undefined; + return (META_REASONING_EFFORT_LEVELS as readonly string[]).includes(effort) + ? { openai: { reasoningEffort: effort } } + : undefined; + }, + cacheFacet: META_CACHE_FACET, + }; +} diff --git a/electron/src/main/providers/drivers/registry.ts b/electron/src/main/providers/drivers/registry.ts index 279e5992..950b1b7b 100644 --- a/electron/src/main/providers/drivers/registry.ts +++ b/electron/src/main/providers/drivers/registry.ts @@ -8,6 +8,7 @@ import { createNativeProviderDrivers } from './native'; import { createCompatibleProviderDrivers, validateGenericEndpoint } from './compatible'; import { createOpenCodeGoProviderDriver } from './opencode-go'; import { createLilacProviderDriver } from './lilac'; +import { createMetaProviderDriver } from './meta'; import { createNeuralwattProviderDriver } from './neuralwatt'; import { ProviderResolutionError } from '../../llm/middleware/error-classification'; @@ -92,6 +93,7 @@ export function createDefaultProviderDriverRegistry(): ProviderDriverRegistry { ...createCompatibleProviderDrivers(), createOpenCodeGoProviderDriver(), createLilacProviderDriver(), + createMetaProviderDriver(), createNeuralwattProviderDriver(), ]); } diff --git a/electron/src/main/providers/facets/cache.ts b/electron/src/main/providers/facets/cache.ts index ba391122..f4b0654a 100644 --- a/electron/src/main/providers/facets/cache.ts +++ b/electron/src/main/providers/facets/cache.ts @@ -59,13 +59,22 @@ export function deriveCacheSessionKey(sessionId: string | undefined): string | u return sessionId ? `orchid-session-${sessionId}` : undefined; } -/** Build the request-level cache options (session routing key, OpenAI). */ +/** + * Build the request-level cache options. OpenAI-style automatic caching rides + * the session routing key (`promptCacheKey`); Responses-protocol providers + * with a retention hint ride the selected ttl id as `promptCacheRetention` + * (for example Meta's `in_memory` | `24h`). + */ export function buildCacheProviderOptions( facet: CacheFacet | undefined, sessionKey: string | undefined, + ttl: string | undefined, ): ReasoningProviderOptions | undefined { - if (!facet?.sessionKey || !sessionKey) return undefined; - return { openai: { promptCacheKey: sessionKey } }; + if (!facet) return undefined; + const openai: Record = {}; + if (facet.sessionKey && sessionKey) openai.promptCacheKey = sessionKey; + if (facet.retentionHint === true && ttl) openai.promptCacheRetention = ttl; + return Object.keys(openai).length > 0 ? { openai } : undefined; } type AnthropicCacheControl = { type: 'ephemeral'; ttl?: string }; diff --git a/electron/src/main/providers/facets/thinking.ts b/electron/src/main/providers/facets/thinking.ts index 8b53b659..7efeb6b0 100644 --- a/electron/src/main/providers/facets/thinking.ts +++ b/electron/src/main/providers/facets/thinking.ts @@ -61,6 +61,24 @@ export const OPENAI_RESPONSES_THINKING_POLICY: ThinkingPolicy = { }, }; +/** + * Meta (Muse Spark) Responses reasoning: raw chain of thought never streams + * and never persists — the only cross-turn carriers are the streamed summary + * and the opt-in encrypted reasoning items, so replay rides encrypted items + * when present (R15, R16). The driver always requests encrypted content and + * stateless storage (see buildThinkingRequestOptions); the knob only declares + * what the API supports. + */ +export const META_THINKING_POLICY: ThinkingPolicy = { + exposure: 'summary', + replay: 'recommended', + knobs: { + summaryProfiles: ['auto', 'detailed', 'concise'], + defaultSummaryProfile: 'auto', + encryptedContentOption: true, + }, +}; + /** * OpenAI Chat Completions: reasoning is opaque — no readable text persists * and replay is impossible, so each turn reasons from scratch. @@ -223,6 +241,19 @@ export function buildThinkingRequestOptions( }, }; } + if (providerId === 'meta') { + // Stateless encrypted replay is the recommended Responses path for + // agentic/tool loops (Meta docs): keep no server state and always carry + // the encrypted chain-of-thought items. `include` cannot combine with + // `previous_response_id`, which Orchid never uses (history is replayed + // client-side). + return { + openai: { + store: false, + include: ['reasoning.encrypted_content'], + }, + }; + } return undefined; } diff --git a/electron/src/main/providers/facets/turn-options.ts b/electron/src/main/providers/facets/turn-options.ts index 576e2bcd..7d5ee1a1 100644 --- a/electron/src/main/providers/facets/turn-options.ts +++ b/electron/src/main/providers/facets/turn-options.ts @@ -58,7 +58,7 @@ export function assembleFacetProviderOptions( const cacheTtl = resolveCacheTtl(input.cacheFacet, input.cacheTtlSelection); providerOptions = mergeProviderOptions( providerOptions, - buildCacheProviderOptions(input.cacheFacet, cacheSessionKey), + buildCacheProviderOptions(input.cacheFacet, cacheSessionKey, cacheTtl), ); return { providerOptions, cacheSessionKey, cacheTtl }; } diff --git a/electron/src/shared/types/provider-facets.ts b/electron/src/shared/types/provider-facets.ts index 9b76e783..c1f56ecb 100644 --- a/electron/src/shared/types/provider-facets.ts +++ b/electron/src/shared/types/provider-facets.ts @@ -45,10 +45,15 @@ export const priceRateSchema = z.object({ export type PriceRate = z.infer; -/** TTL labels key cache-write rate variants and driver TTL options: '5m', '1h'. */ +/** + * TTL labels key cache-write rate variants and driver TTL options: '5m', '1h'. + * Responses-protocol providers also expose retention hints as the same knob + * (Meta `prompt_cache_retention`: 'in_memory' | '24h'), so retention labels + * are accepted alongside duration labels. + */ export const cacheTtlLabelSchema = z.string().regex( - /^\d+[smhd]$/, - 'Expected a TTL label such as 5m or 1h', + /^(?:\d+[smhd]|in_memory)$/, + 'Expected a TTL label such as 5m or 1h, or a retention label such as in_memory', ); export const pricingRateFieldsSchema = z.object({ @@ -203,6 +208,12 @@ export const cacheFacetSchema = z.object({ /** The provider supports a stable session-scoped cache/routing key. */ sessionKey: z.boolean(), ttlOptions: z.array(cacheTtlOptionSchema).min(1).optional(), + /** + * Automatic-cache providers whose TTL selection rides a request-level + * retention hint (for example Meta `prompt_cache_retention`) instead of a + * breakpoint marker. The selected ttl id is sent verbatim (R11). + */ + retentionHint: z.boolean().optional(), }).strict(); export type CacheFacet = z.infer; diff --git a/electron/tests/integration/provider-catalog-tools.test.ts b/electron/tests/integration/provider-catalog-tools.test.ts index 7a351810..51788d2b 100644 --- a/electron/tests/integration/provider-catalog-tools.test.ts +++ b/electron/tests/integration/provider-catalog-tools.test.ts @@ -47,6 +47,7 @@ describe('provider catalog operator tools', () => { 'opencode-go', 'lilac', 'neuralwatt', + 'meta', 'generic-openai-compatible', 'generic-anthropic-compatible', ]); diff --git a/electron/tests/unit/provider-cache.test.ts b/electron/tests/unit/provider-cache.test.ts index 123885af..c7d10960 100644 --- a/electron/tests/unit/provider-cache.test.ts +++ b/electron/tests/unit/provider-cache.test.ts @@ -58,6 +58,18 @@ describe('deriveCacheSessionKey + buildCacheProviderOptions (R10)', () => { it('sends nothing without a session (no key, R12-safe)', () => { expect(buildCacheProviderOptions(OPENAI_CACHE_FACET, undefined)).toBeUndefined(); }); + + it('sends promptCacheRetention for a retention-hint facet (Meta)', async () => { + const { META_CACHE_FACET } = await import('../../src/main/providers/drivers/meta'); + expect(buildCacheProviderOptions(META_CACHE_FACET, undefined, '24h')).toEqual({ + openai: { promptCacheRetention: '24h' }, + }); + expect(buildCacheProviderOptions(META_CACHE_FACET, undefined, 'in_memory')).toEqual({ + openai: { promptCacheRetention: 'in_memory' }, + }); + // No TTL selection: no retention option is sent. + expect(buildCacheProviderOptions(META_CACHE_FACET, undefined, undefined)).toBeUndefined(); + }); }); describe('applyCacheBreakpoints — Anthropic explicit (R10, R11)', () => { diff --git a/electron/tests/unit/provider-meta-catalog-gate.test.ts b/electron/tests/unit/provider-meta-catalog-gate.test.ts new file mode 100644 index 00000000..2bff22ac --- /dev/null +++ b/electron/tests/unit/provider-meta-catalog-gate.test.ts @@ -0,0 +1,30 @@ +/** + * Bundled-catalog gate: the signed dev catalog must pass the same + * trust-policy validation that runs at app startup, with the Meta provider + * declared and every policy pinned by trusted driver code. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { validateCatalogBytes } from '../../src/main/providers/catalog/trust'; + +const CATALOG_PATH = resolve(__dirname, '../../assets/providers/catalog.json'); + +describe('bundled catalog trust validation', () => { + it('accepts the bundled catalog with the Meta provider', () => { + const bytes = readFileSync(CATALOG_PATH); + const { catalog } = validateCatalogBytes(bytes, { + appVersion: '0.1.0', + allowExpired: true, + }); + + const meta = catalog.providers.find((provider) => provider.id === 'meta'); + expect(meta).toBeDefined(); + expect(meta?.supportedProtocols).toEqual(['openai-responses']); + expect(meta?.models.map((model) => model.id)).toEqual([ + 'muse-spark-1.1', + 'muse-spark-1.2', + 'muse-spark-1.2-contributor', + ]); + }); +}); diff --git a/electron/tests/unit/provider-meta-driver.test.ts b/electron/tests/unit/provider-meta-driver.test.ts new file mode 100644 index 00000000..c517130e --- /dev/null +++ b/electron/tests/unit/provider-meta-driver.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { importESM } from '../../src/main/utils/esm-import'; + +vi.mock('../../src/main/utils/esm-import', () => ({ importESM: vi.fn() })); + +const metaModel = { kind: 'meta-openai-responses' }; +const createOpenAI = vi.fn(() => ({ responses: vi.fn(() => metaModel) })); + +describe('Meta provider driver', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(importESM).mockResolvedValue({ createOpenAI }); + }); + + it('constructs the Responses model through the code-owned Meta origin', async () => { + const { META_API_ORIGIN, createMetaLanguageModel } = await import('../../src/main/providers/drivers/meta'); + + await expect(createMetaLanguageModel({ + modelId: 'muse-spark-1.2', + apiKey: 'meta-test-key', + })).resolves.toBe(metaModel); + + expect(createOpenAI).toHaveBeenCalledWith({ + name: 'meta', + baseURL: META_API_ORIGIN, + apiKey: 'meta-test-key', + }); + }); + + it('declares a code-owned, responses-only driver surface', async () => { + const { createMetaProviderDriver, META_API_ORIGIN } = await import('../../src/main/providers/drivers/meta'); + const driver = createMetaProviderDriver(); + + expect(driver.id).toBe('meta'); + expect(driver.allowsCustomEndpoint).toBe(false); + expect(driver.origin).toBe(META_API_ORIGIN); + expect(driver.supportedProtocols).toEqual(['openai-responses']); + expect(driver.supportedAuthMethods).toEqual(['api-key', 'environment']); + }); + + it('emits reasoning effort only for the documented vocabulary', async () => { + const { createMetaProviderDriver, META_REASONING_EFFORT_LEVELS } = await import('../../src/main/providers/drivers/meta'); + const driver = createMetaProviderDriver(); + + for (const level of META_REASONING_EFFORT_LEVELS) { + expect(driver.buildReasoningOptions?.(level)).toEqual({ + openai: { reasoningEffort: level }, + }); + } + // `none` and `max` are rejected by the API and are never emitted; + // numeric budgets do not exist on the Responses protocol. + expect(driver.buildReasoningOptions?.('none')).toBeUndefined(); + expect(driver.buildReasoningOptions?.('max')).toBeUndefined(); + expect(driver.buildReasoningOptions?.(5000)).toBeUndefined(); + }); + + it('resolves the Meta thinking policy for every model', async () => { + const { createMetaProviderDriver, META_THINKING_POLICY } = await import('../../src/main/providers/drivers/meta'); + const driver = createMetaProviderDriver(); + + const model = { + id: 'muse-spark-1.1', + displayName: 'Muse Spark 1.1', + protocol: 'openai-responses', + source: 'catalog', + }; + expect(driver.thinkingPolicy?.(model)).toEqual(META_THINKING_POLICY); + expect(META_THINKING_POLICY).toMatchObject({ + exposure: 'summary', + replay: 'recommended', + knobs: { encryptedContentOption: true }, + }); + }); + + it('declares an automatic cache facet with retention hints only', async () => { + const { createMetaProviderDriver } = await import('../../src/main/providers/drivers/meta'); + const facet = createMetaProviderDriver().cacheFacet; + + expect(facet).toMatchObject({ + mode: 'automatic', + sessionKey: false, + retentionHint: true, + ttlOptions: [ + { id: 'in_memory' }, + { id: '24h' }, + ], + }); + }); +}); + +describe('Meta turn-option assembly', () => { + it('merges effort, stateless encrypted replay, and retention into one request', async () => { + const { assembleFacetProviderOptions } = await import('../../src/main/providers/facets/turn-options'); + const { createMetaProviderDriver, META_THINKING_POLICY } = await import('../../src/main/providers/drivers/meta'); + const driver = createMetaProviderDriver(); + + const result = assembleFacetProviderOptions({ + providerOptions: driver.buildReasoningOptions?.('high'), + thinkingPolicy: META_THINKING_POLICY, + providerId: 'meta', + tierId: undefined, + tierMechanism: undefined, + cacheFacet: driver.cacheFacet, + cacheTtlSelection: '24h', + sessionId: 'session-1', + }); + + expect(result.providerOptions).toEqual({ + openai: { + reasoningEffort: 'high', + store: false, + include: ['reasoning.encrypted_content'], + promptCacheRetention: '24h', + }, + }); + expect(result.cacheTtl).toBe('24h'); + }); +}); diff --git a/electron/tests/unit/thinking-facet.test.ts b/electron/tests/unit/thinking-facet.test.ts index 171661f8..a7a11816 100644 --- a/electron/tests/unit/thinking-facet.test.ts +++ b/electron/tests/unit/thinking-facet.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { ANTHROPIC_THINKING_POLICY, DEFAULT_THINKING_POLICY, + META_THINKING_POLICY, OPENAI_OPAQUE_THINKING_POLICY, OPENAI_RESPONSES_THINKING_POLICY, buildThinkingProviderOptions, @@ -304,6 +305,24 @@ describe('buildThinkingRequestOptions', () => { })).toBeUndefined(); }); + it('emits stateless encrypted replay for Meta regardless of knobs', () => { + expect(buildThinkingRequestOptions(META_THINKING_POLICY, 'meta')).toEqual({ + openai: { + store: false, + include: ['reasoning.encrypted_content'], + }, + }); + // The knob surface does not change the always-on stateless replay contract. + expect(buildThinkingRequestOptions(META_THINKING_POLICY, 'meta', { + encryptedContent: false, + })).toEqual({ + openai: { + store: false, + include: ['reasoning.encrypted_content'], + }, + }); + }); + it('emits nothing for policies without knobs or unknown providers', () => { expect(buildThinkingRequestOptions(DEFAULT_THINKING_POLICY, 'glm')).toBeUndefined(); expect(buildThinkingRequestOptions(OPENAI_OPAQUE_THINKING_POLICY, 'openai')).toBeUndefined(); diff --git a/electron/tests/unit/thinking-replay.test.ts b/electron/tests/unit/thinking-replay.test.ts index a891c455..74f4ce04 100644 --- a/electron/tests/unit/thinking-replay.test.ts +++ b/electron/tests/unit/thinking-replay.test.ts @@ -14,7 +14,7 @@ import { createActor } from 'xstate'; import { agentMachine, } from '../../src/main/agents/xstate/agent-machine'; -import type { Message, ThinkingReplayPayload } from '../../src/shared/types/message'; +import type { Message, ThinkingReplayPayload, ApiMessage } from '../../src/shared/types/message'; import { MessageRole, MessageType, @@ -887,9 +887,136 @@ describe('streamChat thinking replay', () => { }); // --------------------------------------------------------------------------- -// IPC schema size bounds +// Responses-protocol replay structure (phase + reasoning ordering) // --------------------------------------------------------------------------- +describe('Responses-protocol replay structure', () => { + it('tags assistant text that precedes tool calls as commentary phase', () => { + const messages = toModelMessages([ + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Let me check the file first.' }], + tool_calls: [makeToolCall('tc-1', 'read')], + }), + ], { responsesReplay: true }); + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'Let me check the file first.', + providerOptions: { openai: { phase: 'commentary' } }, + }, + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'read', + input: {}, + }, + ], + }); + }); + + it('leaves final-answer text unphased and does not phase without the option', () => { + // Final answer: no tool calls, no phase. + const final = toModelMessages([ + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Done.' }], + }), + ], { responsesReplay: true }); + expect(final[0].content).toBe('Done.'); + + // Without responsesReplay the option is inert for tool-calling text too. + const plain = toModelMessages([ + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Let me check.' }], + tool_calls: [makeToolCall('tc-1', 'read')], + }), + ]); + expect(plain[0].content).toEqual([ + { type: 'text', text: 'Let me check.' }, + { type: 'tool-call', toolCallId: 'tc-1', toolName: 'read', input: {} }, + ]); + }); + + it('inserts a minimal assistant message after a reasoning-only turn before a user message', () => { + const messages = toModelMessages([ + makeApiMessage({ role: MessageRole.USER, content: 'Fix it' }), + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'reasoning', text: 'thinking only' }], + }), + makeApiMessage({ role: MessageRole.USER, content: 'Never mind' }), + ], { responsesReplay: true }); + + expect(messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'assistant', + 'user', + ]); + expect(messages[2]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: '' }], + }); + }); + + it('inserts a minimal assistant message after a trailing reasoning-only turn', () => { + const messages = toModelMessages([ + makeApiMessage({ role: MessageRole.USER, content: 'Fix it' }), + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'reasoning', text: 'interrupted mid-thought' }], + }), + ], { responsesReplay: true }); + + expect(messages.map((message) => message.role)).toEqual(['user', 'assistant', 'assistant']); + }); + + it('does not insert when the reasoning item is followed by an assistant message', () => { + const messages = toModelMessages([ + makeApiMessage({ role: MessageRole.USER, content: 'Fix it' }), + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'reasoning', text: 'plan' }], + }), + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Done.' }], + }), + ], { responsesReplay: true }); + + expect(messages).toHaveLength(3); + expect(messages.map((message) => message.role)).toEqual(['user', 'assistant', 'assistant']); + }); + + it('does not insert reasoning-order guards without responsesReplay', () => { + const messages = toModelMessages([ + makeApiMessage({ role: MessageRole.USER, content: 'Fix it' }), + makeApiMessage({ + role: MessageRole.ASSISTANT, + content: [{ type: 'reasoning', text: 'thinking only' }], + }), + makeApiMessage({ role: MessageRole.USER, content: 'Never mind' }), + ]); + + expect(messages.map((message) => message.role)).toEqual(['user', 'assistant', 'user']); + }); +}); + +function makeApiMessage(overrides: Partial = {}): ApiMessage { + return { + role: MessageRole.USER, + content: '', + tool_calls: undefined, + ...overrides, + } as ApiMessage; +} + + describe('thinkingReplayPayloadSchema size bounds', () => { const base = { providerId: 'anthropic',