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 <gpt-5@openai.com>
This commit is contained in:
xinsngx 2026-05-30 12:12:10 +08:00 committed by GitHub
parent 0fbeaf829e
commit 41b1cd763e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 371 additions and 77 deletions

View file

@ -286,54 +286,18 @@ async function readJsonIfPresent(file: string): Promise<JsonRecord | null> {
}
}
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<OAuthCredential | null> {
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
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<ProviderEntry> {
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<MaskedConfi
const entry = stored[id] || {};
const envKey = readEnvKey(id);
const hasStoredKey = typeof entry.apiKey === 'string' && entry.apiKey.length > 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()

View file

@ -710,7 +710,7 @@ function withMediaRequestInit(
async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
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<RenderResult> {
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);

View file

@ -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<string, unknown>).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': {

View file

@ -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() }

View file

@ -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,

View file

@ -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;
}

View file

@ -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<string, MediaProviderCredentials>,
): 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<string, MediaProviderCredentials>,
): 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();
}

View file

@ -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);

View file

@ -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<DaemonMediaProvid
apiKeyConfigured: Boolean(entry?.configured),
apiKeyTail: entry?.apiKeyTail ?? '',
baseUrl: entry?.baseUrl ?? '',
...(typeof entry?.source === 'string' && entry.source.trim()
? { source: entry.source.trim() }
: {}),
...(typeof entry?.model === 'string' && entry.model.trim()
? { model: entry.model.trim() }
: {}),

View file

@ -174,6 +174,7 @@ export interface MediaProviderCredentials {
model?: string;
apiKeyConfigured?: boolean;
apiKeyTail?: string;
source?: string;
}
export interface ApiProtocolConfig {

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NewProjectPanel } from '../../src/components/NewProjectPanel';
@ -50,4 +50,130 @@ describe('NewProjectPanel media provider badges', () => {
expect(openaiGroup?.textContent).toContain('Configured');
expect(openaiGroup?.textContent).not.toContain('Integrated');
});
it('hides provider models until the provider has usable credentials', () => {
render(
<NewProjectPanel
skills={[]}
designSystems={[]}
defaultDesignSystemId={null}
templates={[]}
onDeleteTemplate={vi.fn()}
promptTemplates={[]}
onCreate={vi.fn()}
mediaProviders={{}}
/>,
);
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(
<NewProjectPanel
skills={[]}
designSystems={[]}
defaultDesignSystemId={null}
templates={[]}
onDeleteTemplate={vi.fn()}
promptTemplates={[]}
onCreate={onCreate}
mediaProviders={{}}
/>,
);
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(
<NewProjectPanel
skills={[]}
designSystems={[]}
defaultDesignSystemId={null}
templates={[]}
onDeleteTemplate={vi.fn()}
promptTemplates={[]}
onCreate={vi.fn()}
mediaProviders={{
openai: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '',
source: 'oauth-codex',
baseUrl: '',
},
}}
/>,
);
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(
<NewProjectPanel
skills={[]}
designSystems={[]}
defaultDesignSystemId={null}
templates={[]}
onDeleteTemplate={vi.fn()}
promptTemplates={[]}
onCreate={onCreate}
mediaProviders={{
volcengine: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '5678',
baseUrl: '',
},
}}
/>,
);
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',
}),
}),
);
});
});

View file

@ -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'],
});
});
});

View file

@ -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) => {

View file

@ -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(() => {