mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
0fbeaf829e
commit
41b1cd763e
14 changed files with 371 additions and 77 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
31
apps/web/src/media/execution-policy.ts
Normal file
31
apps/web/src/media/execution-policy.ts
Normal 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;
|
||||
}
|
||||
38
apps/web/src/media/provider-readiness.ts
Normal file
38
apps/web/src/media/provider-readiness.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export interface MediaProviderCredentials {
|
|||
model?: string;
|
||||
apiKeyConfigured?: boolean;
|
||||
apiKeyTail?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ApiProtocolConfig {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
23
apps/web/tests/media/execution-policy.test.ts
Normal file
23
apps/web/tests/media/execution-policy.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue