From 41b1cd763e95877e95ab16bb8d384ffce0ec00ac Mon Sep 17 00:00:00 2001 From: xinsngx Date: Sat, 30 May 2026 12:12:10 +0800 Subject: [PATCH] fix(media): hide OpenAI OAuth-only image credentials (#3308) * fix(media): ignore OpenAI OAuth tokens Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.1 * fix(media): hide unavailable model providers Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.2 * fix(media): clear unavailable picker models Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.3 * fix(media): keep missing-model projects executable Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.8 --------- Co-authored-by: Codex --- apps/daemon/src/media-config.ts | 78 +++-------- apps/daemon/src/media.ts | 4 +- apps/daemon/tests/media-config.test.ts | 37 +++-- apps/web/src/components/NewProjectPanel.tsx | 21 ++- apps/web/src/components/ProjectView.tsx | 2 + apps/web/src/media/execution-policy.ts | 31 +++++ apps/web/src/media/provider-readiness.ts | 38 ++++++ apps/web/src/providers/daemon.ts | 4 + apps/web/src/state/config.ts | 17 ++- apps/web/src/types.ts | 1 + .../components/NewProjectPanel.media.test.tsx | 128 +++++++++++++++++- apps/web/tests/media/execution-policy.test.ts | 23 ++++ apps/web/tests/providers/sse.test.ts | 34 +++++ apps/web/tests/state/config.test.ts | 30 ++++ 14 files changed, 371 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/media/execution-policy.ts create mode 100644 apps/web/src/media/provider-readiness.ts create mode 100644 apps/web/tests/media/execution-policy.test.ts diff --git a/apps/daemon/src/media-config.ts b/apps/daemon/src/media-config.ts index d4886cbc9..cb35381b2 100644 --- a/apps/daemon/src/media-config.ts +++ b/apps/daemon/src/media-config.ts @@ -286,54 +286,18 @@ async function readJsonIfPresent(file: string): Promise { } } -function tokenFromHermesAuth(data: unknown): string { - const providerToken = readNestedString(data, [ - 'providers', - 'openai-codex', - 'tokens', - 'access_token', - ]); - if (providerToken) return providerToken; - - const pool = - isRecord(data) && isRecord(data.credential_pool) - ? data.credential_pool['openai-codex'] - : null; - if (Array.isArray(pool)) { - for (const item of pool) { - const token = readNestedString(item, ['access_token']); - if (token) return token; - } - } - return ''; +function apiKeyFromCodexAuth(data: unknown): string { + return readNestedString(data, ['OPENAI_API_KEY']); } -function tokenFromCodexAuth(data: unknown): { token: string; source: string } | null { - const oauthToken = readNestedString(data, ['tokens', 'access_token']); - if (oauthToken) return { token: oauthToken, source: 'oauth-codex' }; - - const apiKey = readNestedString(data, ['OPENAI_API_KEY']); - if (apiKey) return { token: apiKey, source: 'codex-auth' }; - - return null; -} - -async function resolveOpenAIOAuthCredential(): Promise { +async function resolveOpenAIAuthFileCredential(): Promise { const home = os.homedir(); - const hermesAuth = await readJsonIfPresent( - path.join(home, '.hermes', 'auth.json'), - ); - const hermesToken = tokenFromHermesAuth(hermesAuth); - if (hermesToken) { - return { apiKey: hermesToken, source: 'oauth-hermes' }; - } - const codexAuth = await readJsonIfPresent( path.join(home, '.codex', 'auth.json'), ); - const codexToken = tokenFromCodexAuth(codexAuth); - if (codexToken) { - return { apiKey: codexToken.token, source: codexToken.source }; + const apiKey = apiKeyFromCodexAuth(codexAuth); + if (apiKey) { + return { apiKey, source: 'codex-auth' }; } return null; @@ -355,9 +319,7 @@ async function resolveXAIOAuthCredential( } // 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json - // when the user ran `hermes auth add xai-oauth`. Mirrors how - // resolveOpenAIOAuthCredential already borrows the openai-codex - // token from the same file, so a user who has already authorized + // when the user ran `hermes auth add xai-oauth`. A user who has already authorized // Hermes doesn't have to run a second OAuth dance inside OD. // (No proactive refresh here — Hermes itself maintains the token, // and we only borrow what is currently fresh.) @@ -380,23 +342,25 @@ async function resolveXAIOAuthCredential( /** * Resolve credentials for a provider. Env vars win, then stored config, - * then OpenAI/Codex OAuth for the OpenAI media provider. + * then provider-specific external credential stores. OpenAI only trusts + * explicit API keys from Codex auth files; Codex/Hermes OAuth tokens are + * not valid proof that the Images API can be called. * Returns { apiKey, baseUrl } where either may be empty string. */ export async function resolveProviderConfig(projectRoot: string, providerId: string): Promise { const stored = await readStored(projectRoot); const entry = stored[providerId] || {}; const envKey = readEnvKey(providerId); - const needsOAuthFallback = !envKey && !entry.apiKey; - const oauth = needsOAuthFallback + const needsExternalCredential = !envKey && !entry.apiKey; + const externalCredential = needsExternalCredential ? providerId === 'openai' - ? await resolveOpenAIOAuthCredential() + ? await resolveOpenAIAuthFileCredential() : providerId === 'grok' ? await resolveXAIOAuthCredential(projectRoot) : null : null; return { - apiKey: envKey || entry.apiKey || oauth?.apiKey || '', + apiKey: envKey || entry.apiKey || externalCredential?.apiKey || '', baseUrl: entry.baseUrl || '', ...(typeof entry.model === 'string' && entry.model.trim() ? { model: entry.model.trim() } @@ -427,20 +391,20 @@ export async function readMaskedConfig(projectRoot: string): Promise 0; - const needsOAuthFallback = !envKey && !hasStoredKey; - const oauth = needsOAuthFallback + const needsExternalCredential = !envKey && !hasStoredKey; + const externalCredential = needsExternalCredential ? id === 'openai' - ? await resolveOpenAIOAuthCredential() + ? await resolveOpenAIAuthFileCredential() : id === 'grok' ? await resolveXAIOAuthCredential(projectRoot) : null : null; providers[id] = { - configured: Boolean(envKey || hasStoredKey || oauth?.apiKey), - source: envKey ? 'env' : hasStoredKey ? 'stored' : oauth?.source || 'unset', + configured: Boolean(envKey || hasStoredKey || externalCredential?.apiKey), + source: envKey ? 'env' : hasStoredKey ? 'stored' : externalCredential?.source || 'unset', // Show last 4 chars only when stored locally; never echo env-var - // or OAuth secrets so power users don't accidentally see them in - // the DOM. + // or borrowed auth-file/OAuth secrets so power users don't + // accidentally see them in the DOM. apiKeyTail: hasStoredKey && entry.apiKey ? entry.apiKey.slice(-4) : '', baseUrl: entry.baseUrl || '', ...(typeof entry.model === 'string' && entry.model.trim() diff --git a/apps/daemon/src/media.ts b/apps/daemon/src/media.ts index 10a297d6b..b9ee0ae2f 100644 --- a/apps/daemon/src/media.ts +++ b/apps/daemon/src/media.ts @@ -710,7 +710,7 @@ function withMediaRequestInit( async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig): Promise { if (!credentials.apiKey) { - throw new Error('no OpenAI credential — configure an API key in Settings, set OPENAI_API_KEY, or refresh Codex/Hermes OAuth'); + throw new Error('no OpenAI credential — configure an API key in Settings or set OPENAI_API_KEY'); } const rawBase = credentials.baseUrl || 'https://api.openai.com/v1'; const azure = detectAzureEndpoint(rawBase); @@ -1117,7 +1117,7 @@ function openaiSpeechFormatFor(fileName: string): string { async function renderOpenAISpeech(ctx: MediaContext, credentials: ProviderConfig, fileName: string): Promise { if (!credentials.apiKey) { - throw new Error('no OpenAI credential — configure an API key in Settings, set OPENAI_API_KEY, or refresh Codex/Hermes OAuth'); + throw new Error('no OpenAI credential — configure an API key in Settings or set OPENAI_API_KEY'); } const rawBase = credentials.baseUrl || 'https://api.openai.com/v1'; const azure = detectAzureEndpoint(rawBase); diff --git a/apps/daemon/tests/media-config.test.ts b/apps/daemon/tests/media-config.test.ts index dfd3ef6bd..ba4fbf084 100644 --- a/apps/daemon/tests/media-config.test.ts +++ b/apps/daemon/tests/media-config.test.ts @@ -21,7 +21,7 @@ const OPENAI_ENV_KEYS = [ 'AZURE_OPENAI_API_KEY', ]; -describe('media-config OpenAI OAuth fallback', () => { +describe('media-config OpenAI auth-file fallback', () => { let homeDir: string; let projectRoot: string; const originalHome = process.env.HOME; @@ -88,7 +88,7 @@ describe('media-config OpenAI OAuth fallback', () => { return (masked.providers as Record).openai; } - it('uses Hermes openai-codex OAuth when no API key is configured', async () => { + it('ignores Hermes openai-codex OAuth for media generation', async () => { await writeHomeJson('.hermes/auth.json', { providers: { 'openai-codex': { @@ -100,15 +100,15 @@ describe('media-config OpenAI OAuth fallback', () => { const resolved = await resolveProviderConfig(projectRoot, 'openai'); const masked = await readMaskedConfig(projectRoot); - expect(resolved.apiKey).toBe('hermes-oauth-token'); + expect(resolved.apiKey).toBe(''); expect(openaiProvider(masked)).toMatchObject({ - configured: true, - source: 'oauth-hermes', + configured: false, + source: 'unset', apiKeyTail: '', }); }); - it('uses Codex OAuth when Hermes has no OpenAI Codex credential', async () => { + it('ignores Codex OAuth tokens for media generation', async () => { await writeHomeJson('.codex/auth.json', { tokens: { access_token: 'codex-oauth-token' }, }); @@ -116,15 +116,32 @@ describe('media-config OpenAI OAuth fallback', () => { const resolved = await resolveProviderConfig(projectRoot, 'openai'); const masked = await readMaskedConfig(projectRoot); - expect(resolved.apiKey).toBe('codex-oauth-token'); + expect(resolved.apiKey).toBe(''); expect(openaiProvider(masked)).toMatchObject({ - configured: true, - source: 'oauth-codex', + configured: false, + source: 'unset', apiKeyTail: '', }); }); - it('keeps stored provider config ahead of OAuth fallbacks', async () => { + it('uses explicit OPENAI_API_KEY from Codex auth files', async () => { + await writeHomeJson('.codex/auth.json', { + tokens: { access_token: 'codex-oauth-token' }, + OPENAI_API_KEY: 'codex-api-key', + }); + + const resolved = await resolveProviderConfig(projectRoot, 'openai'); + const masked = await readMaskedConfig(projectRoot); + + expect(resolved.apiKey).toBe('codex-api-key'); + expect(openaiProvider(masked)).toMatchObject({ + configured: true, + source: 'codex-auth', + apiKeyTail: '', + }); + }); + + it('keeps stored provider config ahead of auth-file fallbacks', async () => { await writeHomeJson('.hermes/auth.json', { providers: { 'openai-codex': { diff --git a/apps/web/src/components/NewProjectPanel.tsx b/apps/web/src/components/NewProjectPanel.tsx index 57d30d7b2..1397d9eef 100644 --- a/apps/web/src/components/NewProjectPanel.tsx +++ b/apps/web/src/components/NewProjectPanel.tsx @@ -23,6 +23,7 @@ import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { fetchPromptTemplate } from '../providers/registry'; import { isStoredMediaProviderEntryPresent } from '../state/config'; +import { isMediaProviderPickerReady } from '../media/provider-readiness'; import type { AudioKind, DesignSystemSummary, @@ -2482,6 +2483,7 @@ function MediaModelCards({ for (const model of models) { const provider = findProvider(model.provider); const providerId = provider?.id ?? model.provider; + if (!isMediaProviderPickerReady(providerId, mediaProviders)) continue; const entry = mediaProviders?.[providerId]; const configured = provider?.credentialsRequired === false || @@ -2512,6 +2514,16 @@ function MediaModelCards({ } return null; }, [groups, value]); + const firstAvailableModelId = groups[0]?.models[0]?.id ?? null; + + useEffect(() => { + if (selected) return; + if (firstAvailableModelId) { + onChange(firstAvailableModelId); + return; + } + if (value) onChange(''); + }, [firstAvailableModelId, onChange, selected, value]); const filteredGroups = useMemo(() => { const q = query.trim().toLowerCase(); @@ -2815,28 +2827,31 @@ function buildMetadata(input: { } if (input.tab === 'media') { if (input.mediaSurface === 'image') { + const imageModel = input.imageModel.trim(); return { kind, - imageModel: input.imageModel, + ...(imageModel ? { imageModel } : {}), imageAspect: input.imageAspect, ...buildPromptTemplateMetadata(input.promptTemplate), ...inspirations, }; } if (input.mediaSurface === 'video') { + const videoModel = input.videoModel.trim(); return { kind, - videoModel: input.videoModel, + ...(videoModel ? { videoModel } : {}), videoAspect: input.videoAspect, videoLength: input.videoLength, ...buildPromptTemplateMetadata(input.promptTemplate), ...inspirations, }; } + const audioModel = input.audioModel.trim(); return { kind, audioKind: input.audioKind, - audioModel: input.audioModel, + ...(audioModel ? { audioModel } : {}), audioDuration: input.audioDuration, ...(input.audioKind === 'speech' && input.voice.trim() ? { voice: input.voice.trim() } diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 02aaa4e62..1bd1abde0 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -168,6 +168,7 @@ import { buildClipboardPrompt } from '../lib/build-clipboard-prompt'; import { copyToClipboard } from '../lib/copy-to-clipboard'; import { effectiveMaxTokens } from '../state/maxTokens'; import { effectiveAgentModelChoice } from './agentModelSelection'; +import { mediaExecutionPolicyForProjectMetadata } from '../media/execution-policy'; import { buildFinalizeCredentialsMissingToast, buildFinalizeRequest, @@ -2762,6 +2763,7 @@ export function ProjectView({ attachments: runAttachments.map((a) => a.path), commentAttachments: runCommentAttachments, research: meta?.research, + mediaExecution: mediaExecutionPolicyForProjectMetadata(project.metadata), model: choice?.model ?? null, reasoning: choice?.reasoning ?? null, locale, diff --git a/apps/web/src/media/execution-policy.ts b/apps/web/src/media/execution-policy.ts new file mode 100644 index 000000000..8b41bd4a2 --- /dev/null +++ b/apps/web/src/media/execution-policy.ts @@ -0,0 +1,31 @@ +import type { MediaExecutionPolicy } from '@open-design/contracts'; +import type { ProjectMetadata } from '../types'; + +function cleanModel(model: unknown): string { + return typeof model === 'string' ? model.trim() : ''; +} + +export function mediaExecutionPolicyForProjectMetadata( + metadata: ProjectMetadata | null | undefined, +): MediaExecutionPolicy | undefined { + if (!metadata) return undefined; + if (metadata.kind === 'image') { + const model = cleanModel(metadata.imageModel); + return model + ? { mode: 'enabled', allowedSurfaces: ['image'], allowedModels: [model] } + : { mode: 'enabled', allowedSurfaces: ['image'] }; + } + if (metadata.kind === 'video') { + const model = cleanModel(metadata.videoModel); + return model + ? { mode: 'enabled', allowedSurfaces: ['video'], allowedModels: [model] } + : { mode: 'enabled', allowedSurfaces: ['video'] }; + } + if (metadata.kind === 'audio') { + const model = cleanModel(metadata.audioModel); + return model + ? { mode: 'enabled', allowedSurfaces: ['audio'], allowedModels: [model] } + : { mode: 'enabled', allowedSurfaces: ['audio'] }; + } + return undefined; +} diff --git a/apps/web/src/media/provider-readiness.ts b/apps/web/src/media/provider-readiness.ts new file mode 100644 index 000000000..44d3c558d --- /dev/null +++ b/apps/web/src/media/provider-readiness.ts @@ -0,0 +1,38 @@ +import { isStoredMediaProviderEntryPresent } from '../state/config'; +import type { MediaProviderCredentials } from '../types'; +import { + findMediaModel, + findProvider, + type MediaProviderId, +} from './models'; + +export function isMediaProviderPickerReady( + providerId: MediaProviderId, + mediaProviders?: Record, +): boolean { + const provider = findProvider(providerId); + if (!provider?.integrated) return false; + if (mediaProviders === undefined) return true; + if (provider.credentialsRequired === false) return true; + const entry = mediaProviders?.[provider.id]; + if (provider.id === 'openai' && isOpenAIOAuthOnlyEntry(entry)) return false; + return isStoredMediaProviderEntryPresent(entry); +} + +export function isMediaModelPickerReady( + modelId: string, + mediaProviders?: Record, +): boolean { + const model = findMediaModel(modelId); + if (!model) return false; + return isMediaProviderPickerReady(model.provider, mediaProviders); +} + +function isOpenAIOAuthOnlyEntry(entry: MediaProviderCredentials | null | undefined): boolean { + const source = entry?.source?.trim(); + return (source === 'oauth-codex' || source === 'oauth-hermes') + && !entry?.apiKey?.trim() + && !entry?.baseUrl?.trim() + && !entry?.model?.trim() + && !entry?.apiKeyTail?.trim(); +} diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 183d79cb6..596de7f3d 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -20,6 +20,7 @@ import type { ChatSseEvent, ChatSseStartPayload, DaemonAgentPayload, + MediaExecutionPolicy, ResearchOptions, RunContextSelection, SseErrorPayload, @@ -223,6 +224,7 @@ export interface DaemonStreamOptions { reasoning?: string | null; research?: ResearchOptions; context?: RunContextSelection; + mediaExecution?: MediaExecutionPolicy; locale?: string; initialLastEventId?: string | null; onRunCreated?: (runId: string) => void; @@ -309,6 +311,7 @@ export async function streamViaDaemon({ reasoning, research, context, + mediaExecution, locale, initialLastEventId, onRunCreated, @@ -342,6 +345,7 @@ export async function streamViaDaemon({ locale, ...(context ? { context } : {}), ...(research ? { research } : {}), + ...(mediaExecution ? { mediaExecution } : {}), ...(analyticsHints ? { analyticsHints } : {}), }; const body = JSON.stringify(request); diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index 64c200e95..af27584fb 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -422,6 +422,7 @@ interface PublicComposioConfigResponse { interface PublicMediaProviderConfigEntry { configured?: boolean; + source?: string; apiKeyTail?: string; baseUrl?: string; model?: string; @@ -507,16 +508,21 @@ export function buildMediaProvidersForDaemonSave( for (const [providerId, currentEntry] of Object.entries(currentProviders ?? {})) { const daemonEntry = daemonProviders?.[providerId]; const apiKey = currentEntry?.apiKey?.trim() ?? ''; + const hasStoredKeyMarker = Boolean( + currentEntry?.apiKeyTail?.trim() + || daemonEntry?.apiKeyTail?.trim(), + ); const preserveApiKey = !apiKey && Boolean( currentEntry?.apiKeyConfigured - && (daemonEntry?.apiKeyConfigured || daemonEntry?.apiKeyTail?.trim()), + && hasStoredKeyMarker, ); - const baseUrl = + const explicitBaseUrl = currentEntry?.baseUrl?.trim() || daemonEntry?.baseUrl?.trim() - || defaultBaseUrlForProvider(providerId); + || ''; const model = currentEntry?.model?.trim() || daemonEntry?.model?.trim() || ''; - if (!apiKey && !preserveApiKey && !baseUrl && !model) continue; + if (!apiKey && !preserveApiKey && !explicitBaseUrl && !model) continue; + const baseUrl = explicitBaseUrl || defaultBaseUrlForProvider(providerId); providers[providerId] = { ...(apiKey ? { apiKey } : {}), ...(preserveApiKey ? { preserveApiKey: true } : {}), @@ -558,6 +564,9 @@ export async function fetchMediaProvidersFromDaemon(): Promise { expect(openaiGroup?.textContent).toContain('Configured'); expect(openaiGroup?.textContent).not.toContain('Integrated'); }); + + it('hides provider models until the provider has usable credentials', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Media' })); + fireEvent.click(screen.getByRole('tab', { name: 'Image' })); + fireEvent.click(screen.getByTestId('model-picker-trigger')); + + expect(screen.queryByText('OpenAI')).toBeNull(); + expect(screen.queryByTestId('model-picker-option-gpt-image-2')).toBeNull(); + }); + + it('does not submit a hidden default model when no image provider is eligible', async () => { + const onCreate = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Media' })); + fireEvent.click(screen.getByRole('tab', { name: 'Image' })); + await waitFor(() => { + expect(screen.getByTestId('model-picker-trigger').textContent).toContain('Pick a model'); + }); + fireEvent.change(screen.getByTestId('new-project-name'), { + target: { value: 'No configured image model' }, + }); + fireEvent.click(screen.getByTestId('create-project')); + + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + kind: 'image', + imageAspect: '1:1', + }), + }), + ); + expect(onCreate.mock.calls[0]?.[0].metadata).not.toHaveProperty('imageModel'); + }); + + it('does not treat OpenAI OAuth-only markers as usable image credentials', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Media' })); + fireEvent.click(screen.getByRole('tab', { name: 'Image' })); + fireEvent.click(screen.getByTestId('model-picker-trigger')); + + expect(screen.queryByText('OpenAI')).toBeNull(); + expect(screen.queryByTestId('model-picker-option-gpt-image-2')).toBeNull(); + }); + + it('switches away from the default OpenAI model when only another provider is configured', () => { + const onCreate = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Media' })); + fireEvent.click(screen.getByRole('tab', { name: 'Image' })); + fireEvent.change(screen.getByTestId('new-project-name'), { + target: { value: 'Configured provider image' }, + }); + fireEvent.click(screen.getByTestId('create-project')); + + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + imageModel: 'doubao-seedream-3-0-t2i-250415', + }), + }), + ); + }); }); diff --git a/apps/web/tests/media/execution-policy.test.ts b/apps/web/tests/media/execution-policy.test.ts new file mode 100644 index 000000000..8ef3c93ce --- /dev/null +++ b/apps/web/tests/media/execution-policy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { mediaExecutionPolicyForProjectMetadata } from '../../src/media/execution-policy'; + +describe('media execution policy for project metadata', () => { + it('keeps image projects without an explicit model enabled so the agent can ask', () => { + expect(mediaExecutionPolicyForProjectMetadata({ kind: 'image' })).toEqual({ + mode: 'enabled', + allowedSurfaces: ['image'], + }); + }); + + it('scopes media projects to their selected model when one is present', () => { + expect(mediaExecutionPolicyForProjectMetadata({ + kind: 'image', + imageModel: 'gpt-image-2', + })).toEqual({ + mode: 'enabled', + allowedSurfaces: ['image'], + allowedModels: ['gpt-image-2'], + }); + }); +}); diff --git a/apps/web/tests/providers/sse.test.ts b/apps/web/tests/providers/sse.test.ts index 2e36e6285..ba739671d 100644 --- a/apps/web/tests/providers/sse.test.ts +++ b/apps/web/tests/providers/sse.test.ts @@ -68,6 +68,40 @@ describe('streamViaDaemon', () => { expect(body.currentPrompt).toBe('post-consent revision'); }); + it('sends run-scoped media execution policy to the daemon', async () => { + const handlers = createDaemonHandlers(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/runs') return jsonResponse({ runId: 'run-1' }); + if (url === '/api/runs/run-1/events') { + return sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n'); + } + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'make an image' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + mediaExecution: { + mode: 'enabled', + allowedSurfaces: ['image'], + allowedModels: ['doubao-seedream-3-0-t2i-250415'], + }, + }); + + const [, createRunInit] = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit]; + const body = JSON.parse(String(createRunInit.body)); + expect(body.mediaExecution).toEqual({ + mode: 'enabled', + allowedSurfaces: ['image'], + allowedModels: ['doubao-seedream-3-0-t2i-250415'], + }); + }); + it('drops prior assistant turns from another agent when composing daemon transcript', async () => { const handlers = createDaemonHandlers(); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { diff --git a/apps/web/tests/state/config.test.ts b/apps/web/tests/state/config.test.ts index f84c2d2fb..4052f5409 100644 --- a/apps/web/tests/state/config.test.ts +++ b/apps/web/tests/state/config.test.ts @@ -633,6 +633,7 @@ describe('fetchMediaProvidersFromDaemon', () => { providers: { openai: { configured: true, + source: 'stored', apiKeyTail: '1234', baseUrl: 'https://daemon.example/v1', model: 'gpt-image-1', @@ -650,6 +651,7 @@ describe('fetchMediaProvidersFromDaemon', () => { openai: { apiKey: '', apiKeyConfigured: true, + source: 'stored', apiKeyTail: '1234', baseUrl: 'https://daemon.example/v1', model: 'gpt-image-1', @@ -732,6 +734,34 @@ describe('buildMediaProvidersForDaemonSave', () => { force: false, }); }); + + it('does not persist default OpenAI base URL for OAuth-only markers', () => { + expect( + buildMediaProvidersForDaemonSave( + { + openai: { + apiKey: '', + apiKeyConfigured: true, + apiKeyTail: '', + baseUrl: '', + source: 'oauth-codex', + }, + }, + { + openai: { + apiKey: '', + apiKeyConfigured: true, + apiKeyTail: '', + baseUrl: '', + source: 'oauth-codex', + }, + }, + ), + ).toEqual({ + providers: {}, + force: false, + }); + }); }); afterEach(() => {