Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ gjc --mpreset codex-medium
gjc --mpreset opencodego --default
```

The `/model` command opens to a preset landing view: presets are grouped by provider with live auth marks (✓/✗), highlighting a group expands its tiers, and selecting a tier shows the full role→model preview before applying for the session or as default. Typing jumps straight to model search, and `Browse all models` opens the classic tabbed model selector. In `/login`, `Add custom provider` is the first option for configuring credentials needed by custom or profile-required providers; after a successful provider login, the matching preset is recommended automatically.
The `/model` command opens to a preset landing view: presets are grouped by provider with live auth marks (✓/✗), highlighting a group expands its tiers, and selecting a tier shows the full role→model preview before applying for the session or as default. Typing jumps straight to model search, and `Browse all models` opens the classic tabbed model selector. In `/login`, `Add custom provider` is the first option for configuring credentials needed by custom or profile-required providers; after a successful provider login, the matching preset is recommended automatically. When that wizard receives a pasted API key, it stores the secret in GJC's credential database and writes only `apiKeyStored: true` to `models.yml`.

MiniMax's OpenAI-compatible endpoint rejects multiple system messages and emits thinking in `reasoning_content`, so pin the public-safe compatibility fields when hand-authoring a custom provider:

Expand Down Expand Up @@ -282,6 +282,7 @@ providers:
### Allowed auth/discovery values

- `auth`: `apiKey` (default), `none`, or `oauth`; for `models.yml` custom models, `oauth` is accepted by schema but does not waive the `apiKey` requirement
- `apiKeyStored: true`: resolve the provider key from GJC's credential database without writing the secret to `models.yml`; `/provider` adds this marker for pasted keys
- `models.yml` is strict: unknown provider/model keys fail validation before provider dispatch, so stale keys such as `requestTransform` or `wireModelId` only work where this document lists them.
- `discovery.type`: `ollama`, `llama.cpp`, `lm-studio`, or `openai-models-list`
- `cacheRetention`: `none`, `short`, or `long`; request-time options win over model/modelOverride values, then provider values, then `GJC_CACHE_RETENTION`, then the runtime default. The runtime default is `short` for most providers, but the Anthropic provider defaults to `long` because the ~5m cache is fragile for long-running subagent workflows. Canonical Anthropic models use top-level automatic caching and emit `ttl: "1h"` when long retention is supported. Claude-family models on non-canonical Anthropic-compatible endpoints default to explicit block markers because compatible proxies commonly inject, rewrite, or reject top-level cache controls; they omit `ttl` unless `compat.supportsLongCacheRetention: true` opts the endpoint into 1-hour retention. For OpenAI Responses, this controls `prompt_cache_retention` only; it does not disable `prompt_cache_key` when a stable session id exists.
Expand Down Expand Up @@ -316,10 +317,11 @@ Use provider-level `headers` for proxy-required headers. Keep the provider `api`
| Intent | Required keys |
| --- | --- |
| Authenticated proxy (recommended) | `auth: apiKey` (default) + `apiKeyEnv: MY_TOKEN` |
| Authenticated proxy, key stored by GJC | `auth: apiKey` (default) + `apiKeyStored: true`; configure it through `/provider` so the credential exists in GJC's credential database |
| Authenticated proxy, key inline | `auth: apiKey` (default) + `apiKey: sk-…` (less safe; stored in plaintext) |
| Genuinely unauthenticated endpoint | `auth: none`, no key |

Omitting both `apiKey` and `apiKeyEnv` while leaving `auth` at its `apiKey` default fails with `Provider <name>: custom models need a credential source, but none is configured.` — the fix is to add one of the rows above, not to change `api` or `baseUrl`.
Omitting all three credential sources (`apiKey`, `apiKeyEnv`, and `apiKeyStored`) while leaving `auth` at its `apiKey` default fails with `Provider <name>: custom models need a credential source, but none is configured.` Add one of the authenticated rows above, or use `auth: none` only when the endpoint is genuinely unauthenticated.

`input` is the model modality list GJC uses to decide whether image content is forwarded. When a custom model omits `input`, GJC defaults to `[text]` (unless a bundled model with the same id contributes a reference). Vision-capable upstream models therefore need an explicit `input: [text, image]`; otherwise `read`/tool images are stripped before the request and replaced with `[image omitted: model does not support vision]`, even if the remote model can see images.

Expand Down Expand Up @@ -391,7 +393,7 @@ modelBindings:
Required:

- `baseUrl`
- A credential source: `apiKeyEnv` or `apiKey`. `auth` selects the scheme, not the credential, so `auth: apiKey` (the default) still needs one of them. Exempt: `auth: none`, and `api: bedrock-converse-stream`, which resolves AWS credentials from its own chain.
- A credential source: `apiKeyEnv`, `apiKey`, or `apiKeyStored: true`. `auth` selects the scheme, not the credential, so `auth: apiKey` (the default) still needs a source. Provider setup writes `apiKeyStored: true` when it saves the credential in auth storage. Exempt: `auth: none`, and `api: bedrock-converse-stream`, which resolves AWS credentials from its own chain.
- `api` at provider level or each model

### Override-only provider (`models` missing or empty)
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- Slack Web API requests now use form encoding instead of JSON, preventing thread reconciliation through `conversations.replies` from failing with `invalid_arguments`.

- Managed replacement cleanup now migrates version-one receipts from earlier releases and recovers canonical exchange placeholders left by interrupted cleanup, so a stale receipt cannot permanently block the next managed session mutation with `managed_replace_cleanup_receipt_invalid`.
- Pasted API keys from the custom-provider wizard now leave an explicit stored-credential marker in `models.yml`, so the generated provider survives immediate registry refresh and later restarts without exposing the secret (#3738).

## [0.12.11] - 2026-08-03

Expand Down Expand Up @@ -92,6 +93,7 @@
### Changed

- Updated the Cursor Eco, Medium, and Pro profiles from Composer 1.5 to distinct Composer 2.5 tiers: standard throughout for Eco, Fast on execution/review/design roles for Medium, and Fast throughout for Pro. Removed inert generic effort suffixes that the Cursor RPC could not transport.
### Fixed

## [0.12.8] - 2026-08-02
### Added
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/src/config/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ interface ProviderValidationConfig {
headers?: Record<string, string>;
apiKey?: string;
apiKeyEnv?: string;
apiKeyStored?: true;
api?: Api;
auth?: ProviderAuthMode;
oauthConfigured?: boolean;
Expand Down Expand Up @@ -326,15 +327,17 @@ function validateProviderConfiguration(
: !usesProviderCredentialChain &&
!config.apiKey &&
!config.apiKeyEnv &&
!config.apiKeyStored &&
(config.auth ?? "apiKey") !== "none";
if (requiresAuth) {
throw new Error(
mode === "runtime-register"
? `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`
: `Provider ${providerName}: custom models need a credential source, but none is configured. ` +
`"auth" only selects the scheme ("auth: apiKey" does not supply a key). ` +
`One of "apiKey", "apiKeyEnv", or "apiKeyStored" is required unless auth is "none". ` +
`Fix by adding "apiKeyEnv: <ENV_VAR>" (recommended) or "apiKey: <literal-key>", ` +
`or set "auth: none" if the endpoint is genuinely unauthenticated.`,
`using a credential stored by provider setup, or setting "auth: none" for an unauthenticated endpoint.`,
);
}
}
Expand Down Expand Up @@ -421,6 +424,7 @@ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsCon
headers: providerConfig.headers,
apiKey: providerConfig.apiKey,
apiKeyEnv: providerConfig.apiKeyEnv,
apiKeyStored: providerConfig.apiKeyStored,
api: providerConfig.api as Api | undefined,
auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
discovery: providerConfig.discovery as ProviderDiscovery | undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/config/models-config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ const ProviderConfigSchema = z
baseUrl: z.string().min(1).optional(),
apiKey: z.string().min(1).optional(),
apiKeyEnv: z.string().min(1).optional(),
apiKeyStored: z.literal(true).optional(),
api: z
.enum([
"openai-completions",
Expand Down
31 changes: 26 additions & 5 deletions packages/coding-agent/src/modes/controllers/selector-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1614,18 +1614,39 @@ export class SelectorController {
this.showSelector(done => {
let wizard: CustomProviderWizardComponent;
const submit = async (input: CustomProviderWizardSubmit): Promise<void> => {
let result: Awaited<ReturnType<typeof addApiCompatibleProvider>>;
try {
const result = await addApiCompatibleProvider(input);
result = await addApiCompatibleProvider(input);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
wizard.setSubmitError(`Provider setup failed: ${message}`);
return;
}
try {
await this.ctx.session.modelRegistry.authStorage.reload();
await this.ctx.session.modelRegistry.refresh("offline");
} catch {
wizard.setSubmitError(
"Provider was configured, but the live provider list could not be reloaded. Retry setup and confirm replacement, or restart GJC.",
);
return;
}
try {
await this.ctx.notifyConfigChanged?.();
this.ctx.showStatus(formatProviderSetupResult(result));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.ctx.showStatus(
`Provider '${result.providerId}' was configured and reloaded, but configuration notification failed: ${message}`,
);
wizard.complete();
done();
this.ctx.ui.requestRender();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
wizard.setSubmitError(`Provider setup failed: ${message}`);
return;
}
this.ctx.showStatus(formatProviderSetupResult(result));
wizard.complete();
done();
this.ctx.ui.requestRender();
};
wizard = new CustomProviderWizardComponent(
input => {
Expand Down
124 changes: 80 additions & 44 deletions packages/coding-agent/src/setup/provider-onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { resolveOAuthStorageProvider } from "@gajae-code/ai";
import { getAgentDbPath, getAgentDir } from "@gajae-code/utils";
import { YAML } from "bun";
import { enqueueAtomicYamlOperation } from "../config/atomic-yaml-patch";
import { withFileLock } from "../config/file-lock";
import { type ModelsConfig, ModelsConfigSchema } from "../config/models-config-schema";
import { compareRankedProviders, famousProviderIndex } from "../config/provider-ranking";
import { AuthStorage } from "../session/auth-storage";
Expand Down Expand Up @@ -308,50 +311,83 @@ async function writeModelsConfig(modelsPath: string, config: ModelsConfig): Prom
export async function addApiCompatibleProvider(input: ProviderSetupInput): Promise<ProviderSetupResult> {
const validated = validateSetupInput(input);
const modelsPath = input.modelsPath ?? getDefaultModelsPath();
const existing = await readModelsConfig(modelsPath);
if (existing.providers?.[validated.providerId] && !input.force) {
throw new Error(`Provider '${validated.providerId}' already exists. Use --force to replace it.`);
}
const provider: ProviderConfig = {
baseUrl: validated.baseUrl,
api: validated.api,
auth: "apiKey",
models: validated.models.map(id => {
const api = validated.modelApi?.[id];
return api ? { id, api } : { id };
}),
};
if (validated.compat) provider.compat = validated.compat;
if (validated.credentialSource === "env") {
provider.apiKeyEnv = validated.apiKey;
} else {
const authStorage = await AuthStorage.create(getAgentDbPath());
try {
await authStorage.set(validated.providerId, { type: "api_key", key: validated.apiKey });
} finally {
authStorage.close();
}
}
const next: ModelsConfig = {
...existing,
providers: {
...(existing.providers ?? {}),
[validated.providerId]: provider,
},
};
await writeModelsConfig(modelsPath, next);
return {
providerId: validated.providerId,
compatibility: validated.compatibility,
api: validated.api,
baseUrl: validated.baseUrl,
modelIds: validated.models,
modelsPath,
redactedApiKey: redactSecret(validated.apiKey),
credentialSource: validated.credentialSource,
preset: validated.preset?.id,
presetName: validated.preset?.name,
};

return await enqueueAtomicYamlOperation(getAgentDbPath(), async () =>
enqueueAtomicYamlOperation(modelsPath, async () =>
withFileLock(modelsPath, async () => {
const existing = await readModelsConfig(modelsPath);
if (existing.providers?.[validated.providerId] && !input.force) {
throw new Error(`Provider '${validated.providerId}' already exists. Use --force to replace it.`);
}

const provider: ProviderConfig = {
baseUrl: validated.baseUrl,
api: validated.api,
auth: "apiKey",
models: validated.models.map(id => {
const api = validated.modelApi?.[id];
return api ? { id, api } : { id };
}),
};
if (validated.compat) provider.compat = validated.compat;

const storageProvider = resolveOAuthStorageProvider(validated.providerId);
const authStorage = await AuthStorage.create(getAgentDbPath());
const previousCredentials = authStorage.getAll()[storageProvider];
let credentialMutationCompleted = false;
try {
if (validated.credentialSource === "env") {
await authStorage.remove(storageProvider);
provider.apiKeyEnv = validated.apiKey;
} else {
await authStorage.set(storageProvider, { type: "api_key", key: validated.apiKey });
provider.apiKeyStored = true;
}
credentialMutationCompleted = true;

const next: ModelsConfig = {
...existing,
providers: {
...(existing.providers ?? {}),
[validated.providerId]: provider,
},
};
await writeModelsConfig(modelsPath, next);
} catch (error) {
if (!credentialMutationCompleted) throw error;
try {
if (previousCredentials) {
await authStorage.set(storageProvider, previousCredentials);
} else {
await authStorage.remove(storageProvider);
}
} catch {
throw new Error(
"Provider setup could not save models configuration and could not restore the previous credential.",
);
}
throw new Error(
"Provider setup could not save models configuration; credential changes were rolled back.",
);
} finally {
authStorage.close();
}

return {
providerId: validated.providerId,
compatibility: validated.compatibility,
api: validated.api,
baseUrl: validated.baseUrl,
modelIds: validated.models,
modelsPath,
redactedApiKey: redactSecret(validated.apiKey),
credentialSource: validated.credentialSource,
preset: validated.preset?.id,
presetName: validated.preset?.name,
};
}),
),
);
}

function isLocalHttpHost(hostname: string): boolean {
Expand Down
39 changes: 39 additions & 0 deletions packages/coding-agent/test/model-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4291,6 +4291,45 @@ describe("ModelRegistry", () => {
expect(message).toContain("unsupportedModelKey");
});

test("loads custom models that declare a stored API key", async () => {
await authStorage.set("stored-provider", { type: "api_key", key: "stored-secret" });
writeRawModelsJson({
"stored-provider": {
baseUrl: "https://api.example.com/v1",
apiKeyStored: true,
api: "openai-completions",
auth: "apiKey",
models: [{ id: "stored-model" }],
},
});

const registry = new ModelRegistry(authStorage, modelsJsonPath);

expect(registry.getError()).toBeUndefined();
expect(registry.find("stored-provider", "stored-model")).toBeDefined();
expect(await registry.getApiKeyForProvider("stored-provider")).toBe("stored-secret");
});

test("still rejects custom models without a declared credential source", () => {
writeRawModelsJson({
"missing-credential": {
baseUrl: "https://api.example.com/v1",
api: "openai-completions",
auth: "apiKey",
models: [{ id: "missing-model" }],
},
});

const registry = new ModelRegistry(authStorage, modelsJsonPath);
const message = String(registry.getError()?.message);

expect(message).toContain("Provider missing-credential: custom models need a credential source");
expect(message).toContain('"auth" only selects the scheme');
expect(message).toContain('"apiKey", "apiKeyEnv", or "apiKeyStored" is required');
expect(message).toContain('"apiKeyEnv: <ENV_VAR>"');
expect(message).toContain('"auth: none"');
});

test("rejects model-level request shaping on non-OpenAI-compatible APIs", () => {
writeRawModelsConfig({
providers: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("provider onboarding wizard red-team", () => {
expect(rendered).toContain("Base URL must use https unless it targets localhost or a loopback address");
});

it("does not report success when config notification rejects and renders the wizard error", async () => {
it("reports a committed provider separately when config notification rejects", async () => {
await withRegistry(async registry => {
let notificationAttempts = 0;
const ctx = createControllerContext(registry, async () => {
Expand All @@ -133,8 +133,10 @@ describe("provider onboarding wizard red-team", () => {
await errorRendered.promise;

expect(notificationAttempts).toBe(1);
expect(ctx.statuses).toEqual([]);
expect(visibleText(wizard)).toContain("Provider setup failed: notification unavailable");
expect(ctx.statuses).toEqual([
"Provider 'notify-failure' was configured and reloaded, but configuration notification failed: notification unavailable",
]);
expect(visibleText(wizard)).not.toContain("Provider setup failed");
});
});

Expand Down
Loading
Loading