From 264226ccb6128ae7919963daa1fff93b5783b8ca Mon Sep 17 00:00:00 2001 From: Zeptiny Date: Wed, 12 Aug 2026 01:01:24 -0300 Subject: [PATCH 1/3] feat(providers): add Meta first-party driver over the Responses API Adds a code-owned Meta driver (api.meta.ai/v1, openai-responses protocol) backed by @ai-sdk/openai's Responses adapter, plus the facet wiring and catalog seeding needed to run Muse Spark models as a first-party provider: - META_THINKING_POLICY: summary exposure with always-on stateless encrypted replay (store: false + include reasoning.encrypted_content) so tool loops keep chain-of-thought without server-side conversation state - buildReasoningOptions validates the documented effort vocabulary (minimal..xhigh); none/max/numeric are rejected like the API does - Cache facet rides prompt_cache_retention (in_memory | 24h) via a new retentionHint on the cache facet; cacheTtlLabelSchema accepts in_memory - Trust policy pins meta to openai-responses with thinking+cache facets; catalog reseeded surgically with the three muse-spark models Full unit suite (3364 tests), typecheck, lint, and runtime-cycle checks pass. --- electron/assets/providers/catalog.json | 201 ++++++++++++++++++ .../provider-catalog/seed-models-dev.ts | 1 + electron/src/main/providers/catalog/trust.ts | 1 + electron/src/main/providers/drivers/meta.ts | 91 ++++++++ .../src/main/providers/drivers/registry.ts | 2 + electron/src/main/providers/facets/cache.ts | 15 +- .../src/main/providers/facets/thinking.ts | 31 +++ .../src/main/providers/facets/turn-options.ts | 2 +- electron/src/shared/types/provider-facets.ts | 17 +- electron/tests/unit/provider-cache.test.ts | 12 ++ .../unit/provider-meta-catalog-gate.test.ts | 30 +++ .../tests/unit/provider-meta-driver.test.ts | 118 ++++++++++ electron/tests/unit/thinking-facet.test.ts | 19 ++ 13 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 electron/src/main/providers/drivers/meta.ts create mode 100644 electron/tests/unit/provider-meta-catalog-gate.test.ts create mode 100644 electron/tests/unit/provider-meta-driver.test.ts 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/providers/catalog/trust.ts b/electron/src/main/providers/catalog/trust.ts index 9bfd60bb..7bfe8d74 100644 --- a/electron/src/main/providers/catalog/trust.ts +++ b/electron/src/main/providers/catalog/trust.ts @@ -35,6 +35,7 @@ export const TRUSTED_CATALOG_PROVIDER_POLICIES: readonly TrustedCatalogProviderP { id: 'google-gemini', authMethods: ['api-key', 'environment'], protocols: ['google-generative-ai'], allowsCustomModels: true }, { id: 'xai', authMethods: ['api-key', 'environment'], protocols: ['xai'], allowsCustomModels: true }, { id: 'opencode-go', authMethods: ['api-key', 'environment'], protocols: ['openai-compatible', 'openai-responses', 'anthropic-messages'], allowsCustomModels: true }, + { id: 'meta', authMethods: ['api-key', 'environment'], protocols: ['openai-responses'], allowsCustomModels: true, facets: ['thinking', 'cache'] }, { id: 'lilac', authMethods: ['api-key', 'environment'], protocols: ['openai-compatible'], allowsCustomModels: true }, { id: 'neuralwatt', authMethods: ['api-key', 'environment'], protocols: ['openai-compatible'], allowsCustomModels: true, facets: ['tiers'] }, { id: 'generic-openai-compatible', authMethods: ['api-key', 'environment', 'none'], protocols: ['openai-compatible', 'openai-responses'], allowsCustomModels: true }, diff --git a/electron/src/main/providers/drivers/meta.ts b/electron/src/main/providers/drivers/meta.ts new file mode 100644 index 00000000..5991dd6e --- /dev/null +++ b/electron/src/main/providers/drivers/meta.ts @@ -0,0 +1,91 @@ +/** + * Meta (dev.meta.ai) first-party driver — the Responses API. + * + * Muse Spark reasons internally; the only cross-turn carriers are streamed + * summaries and opt-in encrypted reasoning items, so this driver always + * requests `include: ["reasoning.encrypted_content"]` with `store: false` + * (stateless replay, no server-side conversation state). Reasoning effort is + * validated against the documented vocabulary (`minimal`..`xhigh`): `"none"` + * and `"max"` are rejected by the API, so they are never emitted. + * + * Model metadata and pricing come from the signed catalog; the driver owns + * only the origin, protocol, and request-option behavior. + */ +import type { LanguageModelV4 } from '@ai-sdk/provider'; +import { importESM } from '../../utils/esm-import'; +import type { EffectiveModel } from '../../../shared/types/provider'; +import type { CacheFacet, ThinkingPolicy } from '../../../shared/types/provider-facets'; +import { META_THINKING_POLICY } from '../facets/thinking'; +export { META_THINKING_POLICY } from '../facets/thinking'; +import type { + DriverCredential, + ProviderDriver, + ReasoningProviderOptions, +} from './types'; + +/** Code-owned Meta API origin; catalog data never controls this base URL. */ +export const META_API_ORIGIN = 'https://api.meta.ai/v1'; + +/** + * Documented `reasoning.effort` vocabulary (models.dev `reasoning_options`). + * Muse Spark rejects `"none"` (disable reasoning) and the Responses API has + * no `"max"` level, so only these values are ever emitted. + */ +export const META_REASONING_EFFORT_LEVELS = [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +] as const; + +/** Meta caching is fully automatic; the retention hint is the only knob. */ +export const META_CACHE_FACET: CacheFacet = { + mode: 'automatic', + sessionKey: false, + retentionHint: true, + ttlOptions: [ + { id: 'in_memory', displayName: 'In-memory' }, + { id: '24h', displayName: '24 hours' }, + ], +}; + +function apiKeyForDriver(credential: DriverCredential): string { + if (credential.kind === 'api-key') return credential.apiKey ?? ''; + return ''; +} + +/** Construct Meta's Responses-protocol model through the OpenAI adapter. */ +export async function createMetaLanguageModel(input: { + readonly modelId: string; + readonly apiKey: string; +}): Promise { + 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/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(); From 48b024c75c4825e12418c359d6249b472e81b54f Mon Sep 17 00:00:00 2001 From: Zeptiny Date: Wed, 12 Aug 2026 01:10:51 -0300 Subject: [PATCH 2/3] feat(providers): enforce Responses replay structure for Meta tool loops Meta's Responses API rejects replayed inputs where a reasoning item is not followed by an assistant message or function_call (HTTP 400), and rejects assistant text replayed as a plain final answer immediately before a function_call. Two replay-path fixes, gated to openai-responses protocol: - toModelMessages tags assistant text that precedes tool calls with openai.phase: "commentary" so the Responses adapter re-emits the message item with its phase (Meta drops/interprets such text as final otherwise) - a reasoning-only turn (cancelled mid-thought, text-less artifact) now gets a minimal assistant message inserted after the reasoning item, the provider-documented workaround, instead of leaving reasoning directly before a user message ThinkingReplayContext carries the frozen model protocol; send.ts and subagent-runner.ts populate it from the request snapshot, and the orchestrator enables the Responses rules only for openai-responses models. --- electron/src/main/agents/subagent-runner.ts | 3 +- electron/src/main/ipc/chat/send.ts | 2 + electron/src/main/llm/history.ts | 7 ++ electron/src/main/llm/model-messages.ts | 58 ++++++++- electron/src/main/llm/orchestrator.ts | 4 +- electron/tests/unit/thinking-replay.test.ts | 131 +++++++++++++++++++- 6 files changed, 198 insertions(+), 7 deletions(-) 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 { }); // --------------------------------------------------------------------------- -// 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', From 8fe6646161902efc8b279bb97a08a4f24ac5b6ef Mon Sep 17 00:00:00 2001 From: Zeptiny Date: Wed, 12 Aug 2026 01:18:16 -0300 Subject: [PATCH 3/3] test(providers): include meta in bundled catalog approval set The integration gate asserting the complete shipped provider set failed CI once the Meta provider was seeded into the bundled catalog. Adds meta to the expected id list; the fixture-based seed test is unaffected because its minimal models.dev capture has no meta entry. --- electron/tests/integration/provider-catalog-tools.test.ts | 1 + 1 file changed, 1 insertion(+) 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', ]);