Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ee239f9
fix(#329): remove sync-regressed protocol dropdown + openai-responses…
mindfn Mar 31, 2026
83665e6
fix: add openai-responses to KNOWN_OC_PROVIDERS datalist suggestions
mindfn Mar 31, 2026
b5d8041
fix: preserve hidden protocol semantics [砚砚/GPT-5.4🐾]
mindfn Apr 1, 2026
ac647d3
chore: add opencode runtime debug logs [砚砚/GPT-5.4🐾]
mindfn Apr 1, 2026
e3ce555
style: fix biome ternary formatting in OpenCode env summary
mindfn Apr 1, 2026
ce42c91
fix: use OPENCODE_CONFIG instead of OPENCODE_CONFIG_DIR for runtime c…
mindfn Apr 2, 2026
d018df9
fix: retire effectiveProtocol from OpenCode routing + add error detai…
mindfn Apr 6, 2026
1ec8b5e
fix(web): remove stale protocol UI assertions from web tests
mindfn Apr 6, 2026
83c56b6
fix: fail fast when api_key account has no API key configured
mindfn Apr 6, 2026
43da7dd
fix(test): isolate env fallback keys in F329 missing-apikey test
mindfn Apr 6, 2026
f9e28ef
fix: remap 'openai' provider name to avoid OpenCode builtin collision
mindfn Apr 7, 2026
e34a2bb
fix(#329): remap CLI -m model prefix for OpenCode builtin provider names
mindfn Apr 7, 2026
4814d53
fix: Codex CLI custom provider model prefix + debug logging
mindfn Apr 7, 2026
680c80d
fix: use --config model= instead of --model for Codex custom provider
mindfn Apr 7, 2026
a7c1151
fix: Codex CLI custom provider sends bare model name (no prefix)
mindfn Apr 7, 2026
f9cce41
ui: bold call-hint URL + provider routing description
mindfn Apr 7, 2026
dfb7ea5
fix: Codex model strip preserves multi-segment slugs (review P2)
mindfn Apr 7, 2026
fbc0924
fix: address review P2 — no model strip + restore protocol UI
mindfn Apr 7, 2026
758dd36
docs: explain why only openai is in OPENCODE_BUILTIN_NAMES
mindfn Apr 7, 2026
55a1d5c
fix: remove Protocol UI dropdown — protocol is auto-inferred
mindfn Apr 7, 2026
9dafffd
feat(F329): add collapsible protocol correction in edit form
mindfn Apr 7, 2026
8d7ec32
fix: update stale test assertion after ROADMAP→BACKLOG rename
mindfn Apr 7, 2026
38b8cf8
fix(#329): prioritize baseUrl over model names in inferProbeProtocol
mindfn Apr 7, 2026
9788d53
Revert "fix(#329): prioritize baseUrl over model names in inferProbeP…
mindfn Apr 7, 2026
d636636
fix(#329): retire account.protocol from runtime env routing
mindfn Apr 7, 2026
2672964
fix(#329): remove protocol matching from provider binding validation
mindfn Apr 7, 2026
5e0bf79
fix(#329): opencode env injection uses account protocol; fix lint and…
mindfn Apr 7, 2026
6ae5946
fix(#329): retire resolveEnvFallbackKey — remove last account.protoco…
mindfn Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 3 additions & 46 deletions packages/api/src/config/account-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,10 @@ export function resolveAnthropicRuntimeProfile(projectRoot: string): AnthropicRu
return { id: 'builtin_anthropic', mode: 'subscription' };
}

const PROTOCOL_ENV_KEY_MAP: Record<AccountProtocol, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
'openai-responses': 'OPENAI_API_KEY',
google: 'GOOGLE_API_KEY',
};

function protocolToClient(protocol: AccountProtocol): BuiltinAccountClient {
return protocol as BuiltinAccountClient;
}

function resolveEnvFallbackKey(protocol: AccountProtocol): string | undefined {
const envKey = PROTOCOL_ENV_KEY_MAP[protocol];
return envKey ? process.env[envKey] : undefined;
}

// Known builtin OAuth account refs — both legacy names and new naming convention.
const BUILTIN_ACCOUNT_MAP: Record<string, { client: BuiltinAccountClient; protocol: AccountProtocol }> = {
claude: { client: 'anthropic', protocol: 'anthropic' },
Expand Down Expand Up @@ -192,7 +180,7 @@ function normalizeProtocol(clientOrProtocol: string): AccountProtocol {

function accountToRuntimeProfile(ref: string, account: AccountConfig): RuntimeProviderProfile {
const credential = readCredential(ref);
const apiKey = credential?.apiKey ?? resolveEnvFallbackKey(account.protocol);
const apiKey = credential?.apiKey;

const isBuiltin = account.authType === 'oauth';
return {
Expand All @@ -209,27 +197,6 @@ function accountToRuntimeProfile(ref: string, account: AccountConfig): RuntimePr

// ── Validation helpers (moved from provider-binding-compat.ts, F136 Phase 4d) ──

/**
* Map a cat client/provider to the protocol it requires.
* Returns null for clients that accept any protocol (opencode).
*/
function expectedProtocolForProvider(provider: CatProvider): AccountProtocol | null {
switch (provider) {
case 'anthropic':
return 'anthropic';
case 'openai':
return 'openai';
case 'google':
return 'google';
case 'dare':
return 'openai';
case 'opencode':
return null; // opencode supports any protocol
default:
return null;
}
}

export function validateRuntimeProviderBinding(
provider: CatProvider,
profile: RuntimeProviderProfile,
Expand All @@ -242,18 +209,8 @@ export function validateRuntimeProviderBinding(
if (expectedClient && profile.kind === 'builtin' && profile.client && profile.client !== expectedClient) {
return `bound provider profile "${profile.id}" is incompatible with client "${provider}"`;
}
// API key accounts must have a protocol compatible with the cat's client.
// e.g. client "anthropic" requires protocol "anthropic"; binding a MiniMax
// account with protocol "openai" would silently use the wrong API format.
if (profile.kind === 'api_key' && profile.protocol) {
const expected = expectedProtocolForProvider(provider);
if (expected && profile.protocol !== expected) {
return (
`client "${provider}" requires "${expected}" protocol, ` +
`but bound account "${profile.id}" uses "${profile.protocol}" protocol`
);
}
}
// Protocol matching removed: protocol is now provider-determined, not an
// account-level attribute. Runtime env injection uses provider directly.
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { dirname, resolve } from 'node:path';
import { type CatId, type ContextHealth, catRegistry, type MessageContent } from '@cat-cafe/shared';
import {
resolveAnthropicRuntimeProfile,
Expand Down Expand Up @@ -40,6 +40,8 @@ import {
OC_API_KEY_ENV,
OC_BASE_URL_ENV,
parseOpenCodeModel,
safeProviderName,
summarizeOpenCodeRuntimeConfigForDebug,
writeOpenCodeRuntimeConfig,
} from '../providers/opencode-config-template.js';

Expand Down Expand Up @@ -307,7 +309,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
let didWriteAudit = false;
let didComplete = false;
let didResetRestoreFailures = false;
let openCodeRuntimeConfigDir: string | undefined;
let openCodeRuntimeConfigPath: string | undefined;
const hostProjectRoot = findMonorepoRoot(process.cwd());

// === CAT_INVOKED 审计 (fire-and-forget, 缅因猫 review P2-3) ===
Expand Down Expand Up @@ -712,25 +714,32 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
}
}

// Determine effective protocol: account.protocol > provider-based default
const defaultProtocolForProvider: Record<string, string> = {
// Fail fast when an api_key account has no credential — otherwise the child
// process silently receives no auth and produces cryptic errors.
if (resolvedAccount?.authType === 'api_key' && !resolvedAccount.apiKey) {
throw new Error(
`account "${resolvedAccount.id}" is configured as api_key but has no API key set — ` +
'add the key in Hub > account settings',
);
}

// Protocol is determined by provider — account.protocol is retired for all
// fixed-protocol providers. OpenCode is the sole exception: it can target
// multiple backends, so its env injection uses the bound account's protocol.
const protocolForProvider: Record<string, string> = {
anthropic: 'anthropic',
opencode: 'anthropic',
openai: 'openai',
google: 'google',
dare: 'openai',
};
const effectiveProtocol =
resolvedAccount?.authType === 'api_key' && resolvedAccount.protocol
const effectiveProtocol = provider
? provider === 'opencode' && resolvedAccount?.protocol
? resolvedAccount.protocol
: provider
? (defaultProtocolForProvider[provider] ?? null)
: null;
: (protocolForProvider[provider] ?? null)
: null;

// Pass protocol hint to CLI via callbackEnv (used by OpenCode/Claude for model prefix)
if (effectiveProtocol) {
callbackEnv.CAT_CAFE_EFFECTIVE_PROTOCOL = effectiveProtocol;
}
// effectiveProtocol is used below for env injection branching (anthropic/openai/google)
// but is NOT passed to callbackEnv — it should not influence CLI routing decisions.

if (effectiveProtocol === 'anthropic') {
if (resolvedAccount?.authType === 'api_key') {
Expand Down Expand Up @@ -827,6 +836,31 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
effectiveProviderName = ocProviderName;
effectiveModel = `${ocProviderName}/${trimmedDefaultModel}`;
}

if (provider === 'opencode') {
log.debug(
{
catId,
invocationId,
boundAccountRef: boundAccountRef ?? null,
resolvedAccount: resolvedAccount
? {
id: resolvedAccount.id,
authType: resolvedAccount.authType,
baseUrl: resolvedAccount.baseUrl ?? null,
modelCount: resolvedAccount.models?.length ?? 0,
hasApiKey: Boolean(resolvedAccount.apiKey),
}
: null,
defaultModel: trimmedDefaultModel ?? null,
ocProviderName: ocProviderName ?? null,
parsedOpenCodeModel,
effectiveProviderName: effectiveProviderName ?? null,
effectiveModel: effectiveModel ?? null,
},
'Resolved OpenCode runtime inputs',
);
}
// fix(#280): explicit ocProviderName means we must force the F189 path so the
// effective "provider/model" string is injected into opencode, even for builtin
// providers. For legacy members without ocProviderName, only synthesize runtime
Expand All @@ -840,19 +874,49 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
effectiveProviderName &&
(hasExplicitOcProvider || !getOpenCodeKnownModels().has(effectiveModel))
) {
callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE = effectiveModel;
const apiType = deriveOpenCodeApiType(resolvedAccount.protocol, effectiveProviderName);
// Remap model prefix when provider name collides with OpenCode builtins
// (e.g. 'openai/gpt-4o' → 'openai-compat/gpt-4o') so the CLI -m arg
// matches the remapped provider key in opencode.json.
const safeProvider = safeProviderName(effectiveProviderName);
const safeModel =
safeProvider !== effectiveProviderName && effectiveModel.startsWith(`${effectiveProviderName}/`)
? `${safeProvider}/${effectiveModel.slice(effectiveProviderName.length + 1)}`
: effectiveModel;
callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE = safeModel;
const apiType = deriveOpenCodeApiType(effectiveProviderName);
const rawModels = resolvedAccount.models?.length ? resolvedAccount.models : [effectiveModel];
openCodeRuntimeConfigDir = writeOpenCodeRuntimeConfig(projectRoot, catId as string, invocationId, {
const runtimeConfigOptions = {
providerName: effectiveProviderName,
models: rawModels,
defaultModel: effectiveModel,
apiType,
hasBaseUrl: Boolean(resolvedAccount.baseUrl),
});
callbackEnv.OPENCODE_CONFIG_DIR = openCodeRuntimeConfigDir;
} as const;
openCodeRuntimeConfigPath = writeOpenCodeRuntimeConfig(
projectRoot,
catId as string,
invocationId,
runtimeConfigOptions,
);
callbackEnv.OPENCODE_CONFIG = openCodeRuntimeConfigPath;
if (resolvedAccount.apiKey) callbackEnv[OC_API_KEY_ENV] = resolvedAccount.apiKey;
if (resolvedAccount.baseUrl) callbackEnv[OC_BASE_URL_ENV] = resolvedAccount.baseUrl;
log.debug(
{
catId,
invocationId,
openCodeConfigPath: openCodeRuntimeConfigPath,
apiType,
callbackEnvSummary: {
opencodeConfig: callbackEnv.OPENCODE_CONFIG,
ocBaseUrl: callbackEnv[OC_BASE_URL_ENV] ?? null,
ocApiKeyPresent: Boolean(callbackEnv[OC_API_KEY_ENV]),
modelOverride: callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE ?? null,
},
runtimeConfigSummary: summarizeOpenCodeRuntimeConfigForDebug(runtimeConfigOptions),
},
'Prepared OpenCode runtime config',
);
}

// F-BLOAT: Only inject staticIdentity (systemPrompt) on new sessions for cats
Expand Down Expand Up @@ -1618,7 +1682,8 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
// F118: Release session mutex (idempotent — safe if never acquired)
sessionMutexRelease?.();

if (openCodeRuntimeConfigDir) {
if (openCodeRuntimeConfigPath) {
const openCodeRuntimeConfigDir = dirname(openCodeRuntimeConfigPath);
await rm(openCodeRuntimeConfigDir, { recursive: true, force: true }).catch((err) => {
log.warn({ invocationId, path: openCodeRuntimeConfigDir, err }, 'Failed to remove OpenCode runtime config dir');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@ export class CodexAgentService implements AgentService {

const sandboxMode = getCodexSandboxMode();
const approvalPolicy = getCodexApprovalPolicy();
const modelArgs = ['--model', effectiveModel];
const effortLevel = getCatEffort(this.catId as string);
const reasoningArgs = ['--config', `model_reasoning_effort="${effortLevel}"`];
const approvalArgs = ['--config', `approval_policy="${approvalPolicy}"`];
Expand Down Expand Up @@ -272,6 +271,14 @@ export class CodexAgentService implements AgentService {
]
: [];

// Codex CLI sends the model name verbatim to the API (model_info.slug).
// model_provider="custom" only controls which provider entry (base_url, env_key) to use.
// The model name is user-configured (no system-added prefix to strip).
// Use --config model=... instead of --model to bypass the CLI's built-in metadata lookup
// for custom providers (non-builtin models trigger a cosmetic warning via --model).
const cliModel = effectiveModel;
const modelArgs: string[] = customBaseUrl ? ['--config', `model=${toTomlString(cliModel)}`] : ['--model', cliModel];

// resume 子命令不接受 --sandbox(sandbox 在创建时已锁定)
// --add-dir .git: 允许写入 .git/ 目录(index.lock、objects、refs),解锁 git commit
// 注意:旧 session resume 时沿用创建时的沙箱参数,不会带 --add-dir。
Expand Down Expand Up @@ -312,7 +319,7 @@ export class CodexAgentService implements AgentService {
...promptArgs,
];

const metadata: MessageMetadata = { provider: 'openai', model: effectiveModel };
const metadata: MessageMetadata = { provider: 'openai', model: cliModel };
const auditContext = options?.auditContext;
const recentStreamErrors: string[] = [];

Expand Down Expand Up @@ -356,6 +363,22 @@ export class CodexAgentService implements AgentService {
return;
}

log.debug(
{
catId: this.catId,
command: codexCommand,
model: cliModel,
originalModel: effectiveModel,
customBaseUrl: customBaseUrl ?? null,
sessionId: options?.sessionId ?? null,
invocationId: options?.invocationId ?? null,
cwd: options?.workingDirectory ?? null,
authMode,
argCount: args.length,
},
'Invoking Codex CLI',
);

const cliOpts = {
command: codexCommand,
args,
Expand Down
Loading
Loading