@gajae-code/ai 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.0] - 2026-06-22
6
+
7
+ ### Added
8
+
9
+ - Added the bundled `google-gemini-cli/gemini-3.5-flash` model entry so Gemini CLI users can select the model from the static registry (#965).
10
+
5
11
  ## [0.6.4] - 2026-06-20
6
12
 
7
13
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  import type { AuthCredential } from "../auth-storage";
2
- import type { CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types";
2
+ import type { CredentialDisableResponse, CredentialIfAbsentUploadResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types";
3
3
  export interface AuthBrokerClientOptions {
4
4
  /** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
5
5
  url: string;
@@ -63,4 +63,5 @@ export declare class AuthBrokerClient {
63
63
  refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
64
64
  disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
65
65
  uploadCredential(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialUploadResponse>;
66
+ uploadCredentialIfAbsent(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialIfAbsentUploadResponse>;
66
67
  }
@@ -1,4 +1,4 @@
1
- import { type AuthCredential, type AuthCredentialStore, type OAuthCredential, type StoredAuthCredential } from "../auth-storage";
1
+ import { type AuthCredential, type AuthCredentialIfAbsentResult, type AuthCredentialStore, type OAuthCredential, type StoredAuthCredential } from "../auth-storage";
2
2
  import type { Provider } from "../types";
3
3
  import type { UsageReport } from "../usage";
4
4
  import type { OAuthCredentials } from "../utils/oauth/types";
@@ -44,6 +44,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
44
44
  }): Promise<void>;
45
45
  replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[];
46
46
  upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[];
47
+ upsertAuthCredentialForProviderIfAbsent(_provider: string, _credential: AuthCredential): AuthCredentialIfAbsentResult;
47
48
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void;
48
49
  /**
49
50
  * Upsert a single credential through the broker. The broker server is the
@@ -53,6 +54,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
53
54
  * any concurrent peer (refresh, generation bump) stays in sync.
54
55
  */
55
56
  upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
57
+ upsertAuthCredentialRemoteIfAbsent(provider: string, credential: AuthCredential): Promise<AuthCredentialIfAbsentResult>;
56
58
  /**
57
59
  * Replace-all semantics: disable every active credential for the provider,
58
60
  * then upload each of the new credentials. Used by API-key login so a new
@@ -5,7 +5,7 @@
5
5
  * clients use `access` tokens directly and call back to the broker when a
6
6
  * credential expires or a 401 surfaces on a supposedly-fresh credential.
7
7
  */
8
- import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
8
+ import type { AuthCredential, AuthCredentialIfAbsentReason, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
9
9
  import type { UsageReport } from "../usage";
10
10
  /** GET /v1/healthz response body. */
11
11
  export interface HealthzResponse {
@@ -56,6 +56,11 @@ export interface CredentialUploadRequest {
56
56
  export interface CredentialUploadResponse {
57
57
  entries: AuthCredentialSnapshotEntry[];
58
58
  }
59
+ export interface CredentialIfAbsentUploadResponse {
60
+ inserted: boolean;
61
+ reason: AuthCredentialIfAbsentReason;
62
+ entries: AuthCredentialSnapshotEntry[];
63
+ }
59
64
  /**
60
65
  * SSE event kinds emitted on `GET /v1/snapshot/stream`. The same value is set
61
66
  * as the SSE `event:` name (load-bearing for clients) **and** embedded as a
@@ -410,3 +410,33 @@ export declare const credentialUploadResponseSchema: z.ZodObject<{
410
410
  identityKey: z.ZodNullable<z.ZodString>;
411
411
  }, z.core.$strict>>;
412
412
  }, z.core.$strict>;
413
+ export declare const credentialIfAbsentUploadResponseSchema: z.ZodObject<{
414
+ inserted: z.ZodBoolean;
415
+ reason: z.ZodEnum<{
416
+ inserted: "inserted";
417
+ "skipped-existing": "skipped-existing";
418
+ "skipped-existing-config": "skipped-existing-config";
419
+ "skipped-existing-env": "skipped-existing-env";
420
+ "skipped-existing-fallback": "skipped-existing-fallback";
421
+ "skipped-existing-runtime": "skipped-existing-runtime";
422
+ "skipped-invalid": "skipped-invalid";
423
+ }>;
424
+ entries: z.ZodArray<z.ZodObject<{
425
+ id: z.ZodNumber;
426
+ provider: z.ZodString;
427
+ credential: z.ZodDiscriminatedUnion<[z.ZodObject<{
428
+ type: z.ZodLiteral<"oauth">;
429
+ access: z.ZodString;
430
+ expires: z.ZodNumber;
431
+ enterpriseUrl: z.ZodOptional<z.ZodString>;
432
+ projectId: z.ZodOptional<z.ZodString>;
433
+ email: z.ZodOptional<z.ZodString>;
434
+ accountId: z.ZodOptional<z.ZodString>;
435
+ refresh: z.ZodLiteral<"__remote__">;
436
+ }, z.core.$strict>, z.ZodObject<{
437
+ type: z.ZodLiteral<"api_key">;
438
+ key: z.ZodString;
439
+ }, z.core.$strict>], "type">;
440
+ identityKey: z.ZodNullable<z.ZodString>;
441
+ }, z.core.$strict>>;
442
+ }, z.core.$strict>;
@@ -102,6 +102,19 @@ export interface AuthCredentialSnapshotEntry {
102
102
  credential: SnapshotCredential;
103
103
  identityKey: string | null;
104
104
  }
105
+ export type AuthCredentialIfAbsentReason = "inserted" | "skipped-existing" | "skipped-existing-runtime" | "skipped-existing-config" | "skipped-existing-env" | "skipped-existing-fallback" | "skipped-invalid";
106
+ export interface AuthCredentialIfAbsentResult {
107
+ inserted: boolean;
108
+ reason: AuthCredentialIfAbsentReason;
109
+ provider: string;
110
+ entries: StoredAuthCredential[];
111
+ }
112
+ export interface AuthCredentialIfAbsentSnapshotResult {
113
+ inserted: boolean;
114
+ reason: AuthCredentialIfAbsentReason;
115
+ provider: string;
116
+ entries: AuthCredentialSnapshotEntry[];
117
+ }
105
118
  /**
106
119
  * Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and
107
120
  * served by the auth-broker server on `GET /v1/snapshot`.
@@ -128,6 +141,7 @@ export interface AuthCredentialStore {
128
141
  tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
129
142
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
130
143
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
144
+ upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
131
145
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
132
146
  getCache(key: string, options?: {
133
147
  includeExpired?: boolean;
@@ -201,6 +215,7 @@ export interface AuthCredentialStore {
201
215
  * post-write read path is consistent.
202
216
  */
203
217
  upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
218
+ upsertAuthCredentialRemoteIfAbsent?(provider: string, credential: AuthCredential): Promise<AuthCredentialIfAbsentResult>;
204
219
  /**
205
220
  * Optional async write hook for replace-all semantics (e.g. API-key login
206
221
  * overwriting any previous keys for the same provider). When present,
@@ -417,6 +432,7 @@ export declare class AuthStorage {
417
432
  * Set credential for a provider.
418
433
  */
419
434
  set(provider: string, credential: AuthCredentialEntry): Promise<void>;
435
+ importCredentialIfAbsent(provider: string, credential: AuthCredential): Promise<AuthCredentialIfAbsentSnapshotResult>;
420
436
  /**
421
437
  * Remove credential for a provider.
422
438
  */
@@ -615,6 +631,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
615
631
  listAuthCredentials(provider?: string): StoredAuthCredential[];
616
632
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
617
633
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
634
+ upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
618
635
  updateAuthCredential(id: number, credential: AuthCredential): void;
619
636
  deleteAuthCredential(id: number, disabledCause: string): void;
620
637
  /**
@@ -14,3 +14,6 @@ export declare function cursorModelManagerOptions(config?: CursorModelManagerCon
14
14
  export interface ZaiModelManagerConfig {
15
15
  }
16
16
  export declare function zaiModelManagerOptions(_config?: ZaiModelManagerConfig): ModelManagerOptions<"anthropic-messages">;
17
+ export interface GlmZcodeModelManagerConfig {
18
+ }
19
+ export declare function glmZcodeModelManagerOptions(_config?: GlmZcodeModelManagerConfig): ModelManagerOptions<"anthropic-messages">;
@@ -48,7 +48,7 @@ export interface ThinkingConfig {
48
48
  /** Provider-specific transport used to encode the selected effort. */
49
49
  mode: ThinkingControlMode;
50
50
  }
51
- export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
51
+ export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "glm-zcode" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
52
52
  export type Provider = KnownProvider | string;
53
53
  import type { Effort } from "./model-thinking";
54
54
  /** Token budgets for each thinking level (token-based providers only) */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
3
+ *
4
+ * Replicates the reverse-engineered ZCode desktop-app login to Z.AI/GLM. This
5
+ * is NOT an official Z.AI OAuth client: it reuses ZCode's authorize page,
6
+ * broker, and a custom-protocol redirect. It may break at any time and may
7
+ * violate ZCode/Z.AI Terms of Service. Endpoints and the client id are
8
+ * overridable via `ZCODE_OAUTH_*` environment variables.
9
+ *
10
+ * Login flow:
11
+ * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
12
+ * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
13
+ * 2. Broker: POST {broker} { provider: "zai", code, redirect_uri, state }
14
+ * → { code: 0, data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> }, expires_in } }
15
+ *
16
+ * Credential mapping (verified against the ZCode host bundle):
17
+ * - `access` = the **ZCode JWT** (`data.token`). This is the GLM coding-plan
18
+ * model credential: ZCode stores it under the `zcodejwttoken`
19
+ * key and sends it as `Authorization: Bearer` to the coding-plan
20
+ * gateway `${ZCODE_PLAN_ANTHROPIC_BASE_URL}` (default
21
+ * https://zcode.z.ai/api/v1/zcode-plan/anthropic), which
22
+ * validates the ZCode session and injects the upstream GLM key
23
+ * server-side. Model traffic does NOT go to api.z.ai directly.
24
+ * - `refresh` = the upstream Z.AI OAuth access token (`data.zai.access_token`),
25
+ * kept for identity/userinfo only. ZCode's separate z/login
26
+ * "business token" is used by ZCode for billing/userinfo, NOT
27
+ * model calls, so it is intentionally never minted here.
28
+ *
29
+ * The ZCode JWT reaches the Anthropic-messages request as a plain bearer
30
+ * automatically (the gateway base is not api.anthropic.com). This provider must
31
+ * NEVER force `isOAuth=true`, which would route GLM into the Claude-Code OAuth
32
+ * header branch (claude-cli UA, `claude_` tool prefixes, Claude system prompt).
33
+ */
34
+ import { OAuthCallbackFlow } from "./callback-server";
35
+ import type { OAuthController, OAuthCredentials } from "./types";
36
+ export declare const GLM_ZCODE_REFRESH_SKEW_MS: number;
37
+ /** Default endpoints / client id. Override via the matching `ZCODE_OAUTH_*` env vars. */
38
+ export declare const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize";
39
+ export declare const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg";
40
+ export declare const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback";
41
+ export declare const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token";
42
+ export declare const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo";
43
+ /**
44
+ * Default coding-plan ("start plan") Anthropic gateway base. ZCode derives this
45
+ * as `${zcodeBackend}/api/v1/zcode-plan/anthropic`. Model requests go here
46
+ * (NOT api.z.ai), authenticated with the ZCode JWT. Override via
47
+ * `ZCODE_PLAN_ANTHROPIC_BASE_URL`. Exported for the model descriptor / catalog.
48
+ */
49
+ export declare const GLM_ZCODE_PLAN_ANTHROPIC_BASE_URL = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
50
+ type FetchImpl = typeof globalThis.fetch;
51
+ /**
52
+ * The provider is configured whenever a client id is available. The real ZCode
53
+ * client id ships as the default, so this is true unless explicitly cleared.
54
+ */
55
+ export declare function isGlmZcodeOAuthConfigured(): boolean;
56
+ export interface GlmZcodeOAuthFlowOptions {
57
+ fetch?: FetchImpl;
58
+ }
59
+ export declare class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
60
+ #private;
61
+ constructor(ctrl: OAuthController, options?: GlmZcodeOAuthFlowOptions);
62
+ generateAuthUrl(state: string, redirectUri: string): Promise<{
63
+ url: string;
64
+ instructions?: string;
65
+ }>;
66
+ exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials>;
67
+ }
68
+ export declare function loginGlmZcode(ctrl: OAuthController, options?: GlmZcodeOAuthFlowOptions): Promise<OAuthCredentials>;
69
+ export interface GlmZcodeRefreshOptions {
70
+ signal?: AbortSignal;
71
+ fetch?: FetchImpl;
72
+ }
73
+ /**
74
+ * The ZCode session JWT is the model credential. ZCode mints it from a one-time
75
+ * authorization code via the broker and exposes no documented refresh grant, so
76
+ * there is no autonomous refresh: an expired credential requires re-login
77
+ * (`/login glm-zcode`). Never return an expired credential as valid.
78
+ */
79
+ export declare function refreshGlmZcodeToken(_credentials: OAuthCredentials, _options?: AbortSignal | GlmZcodeRefreshOptions): Promise<OAuthCredentials>;
80
+ export {};
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
10
+ export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.6.5",
4
+ "version": "0.7.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@gajae-code/utils": "0.6.5",
46
+ "@gajae-code/utils": "0.7.0",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -11,6 +11,7 @@ import type { AuthCredential } from "../auth-storage";
11
11
  import type {
12
12
  CredentialDisableRequest,
13
13
  CredentialDisableResponse,
14
+ CredentialIfAbsentUploadResponse,
14
15
  CredentialRefreshResponse,
15
16
  CredentialUploadRequest,
16
17
  CredentialUploadResponse,
@@ -21,6 +22,7 @@ import type {
21
22
  } from "./types";
22
23
  import {
23
24
  credentialDisableResponseSchema,
25
+ credentialIfAbsentUploadResponseSchema,
24
26
  credentialRefreshResponseSchema,
25
27
  credentialUploadResponseSchema,
26
28
  healthzResponseSchema,
@@ -262,6 +264,19 @@ export class AuthBrokerClient {
262
264
  }) as Promise<CredentialUploadResponse>;
263
265
  }
264
266
 
267
+ async uploadCredentialIfAbsent(
268
+ provider: string,
269
+ credential: AuthCredential,
270
+ signal?: AbortSignal,
271
+ ): Promise<CredentialIfAbsentUploadResponse> {
272
+ const body: CredentialUploadRequest = { provider, credential };
273
+ return this.#request("POST", "/v1/credential/if-absent", {
274
+ body,
275
+ schema: credentialIfAbsentUploadResponseSchema,
276
+ signal,
277
+ }) as Promise<CredentialIfAbsentUploadResponse>;
278
+ }
279
+
265
280
  async #request<TSchema extends ZodType>(
266
281
  method: "GET" | "POST",
267
282
  path: string,
@@ -11,6 +11,7 @@ import { scheduler } from "node:timers/promises";
11
11
  import { logger } from "@gajae-code/utils";
12
12
  import {
13
13
  type AuthCredential,
14
+ type AuthCredentialIfAbsentResult,
14
15
  type AuthCredentialSnapshotEntry,
15
16
  type AuthCredentialStore,
16
17
  type OAuthCredential,
@@ -308,6 +309,15 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
308
309
  );
309
310
  }
310
311
 
312
+ upsertAuthCredentialForProviderIfAbsent(
313
+ _provider: string,
314
+ _credential: AuthCredential,
315
+ ): AuthCredentialIfAbsentResult {
316
+ throw new Error(
317
+ "RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker login <provider>` to mutate credentials.",
318
+ );
319
+ }
320
+
311
321
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
312
322
  throw new Error(
313
323
  "RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker logout <provider>` to mutate credentials.",
@@ -328,6 +338,16 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
328
338
  return this.listAuthCredentials(provider);
329
339
  }
330
340
 
341
+ async upsertAuthCredentialRemoteIfAbsent(
342
+ provider: string,
343
+ credential: AuthCredential,
344
+ ): Promise<AuthCredentialIfAbsentResult> {
345
+ const { inserted, reason, entries } = await this.#client.uploadCredentialIfAbsent(provider, credential);
346
+ this.#applyProviderEntries(provider, entries);
347
+ this.#maybeRefreshSnapshot("upload-if-absent");
348
+ return { inserted, reason, provider, entries: this.listAuthCredentials(provider) };
349
+ }
350
+
331
351
  /**
332
352
  * Replace-all semantics: disable every active credential for the provider,
333
353
  * then upload each of the new credentials. Used by API-key login so a new
@@ -15,6 +15,7 @@ import { parseBind } from "../utils/parse-bind";
15
15
  import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
16
16
  import type {
17
17
  CredentialDisableResponse,
18
+ CredentialIfAbsentUploadResponse,
18
19
  CredentialRefreshResponse,
19
20
  CredentialUploadResponse,
20
21
  HealthzResponse,
@@ -592,6 +593,32 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
592
593
  const response: CredentialDisableResponse = { ok: true };
593
594
  return json(200, response);
594
595
  }
596
+ if (req.method === "POST" && pathname === "/v1/credential/if-absent") {
597
+ const parsed = await parseBody(req, credentialUploadRequestSchema);
598
+ if (!parsed.ok) return parsed.response;
599
+ const { provider, credential } = parsed.data;
600
+ try {
601
+ const result = await opts.storage.importCredentialIfAbsent(provider, credential);
602
+ logger.info("auth-broker credential import-if-absent", {
603
+ provider,
604
+ type: credential.type,
605
+ inserted: result.inserted,
606
+ reason: result.reason,
607
+ providerTotal: result.entries.length,
608
+ peer,
609
+ });
610
+ const response: CredentialIfAbsentUploadResponse = {
611
+ inserted: result.inserted,
612
+ reason: result.reason,
613
+ entries: result.entries,
614
+ };
615
+ return json(200, response);
616
+ } catch (error) {
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ logger.warn("auth-broker upload-if-absent failed", { provider, type: credential.type, peer });
619
+ return json(500, { error: message });
620
+ }
621
+ }
595
622
  if (req.method === "POST" && pathname === "/v1/credential") {
596
623
  const parsed = await parseBody(req, credentialUploadRequestSchema);
597
624
  if (!parsed.ok) return parsed.response;
@@ -6,7 +6,12 @@
6
6
  * credential expires or a 401 surfaces on a supposedly-fresh credential.
7
7
  */
8
8
 
9
- import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
9
+ import type {
10
+ AuthCredential,
11
+ AuthCredentialIfAbsentReason,
12
+ AuthCredentialSnapshot,
13
+ AuthCredentialSnapshotEntry,
14
+ } from "../auth-storage";
10
15
  import type { UsageReport } from "../usage";
11
16
 
12
17
  /** GET /v1/healthz response body. */
@@ -68,6 +73,12 @@ export interface CredentialUploadResponse {
68
73
  entries: AuthCredentialSnapshotEntry[];
69
74
  }
70
75
 
76
+ export interface CredentialIfAbsentUploadResponse {
77
+ inserted: boolean;
78
+ reason: AuthCredentialIfAbsentReason;
79
+ entries: AuthCredentialSnapshotEntry[];
80
+ }
81
+
71
82
  /**
72
83
  * SSE event kinds emitted on `GET /v1/snapshot/stream`. The same value is set
73
84
  * as the SSE `event:` name (load-bearing for clients) **and** embedded as a
@@ -185,6 +185,15 @@ export const credentialDisableResponseSchema = z
185
185
  .strict();
186
186
 
187
187
  // ─── Upload ────────────────────────────────────────────────────────────────
188
+ const credentialIfAbsentReasonSchema = z.enum([
189
+ "inserted",
190
+ "skipped-existing",
191
+ "skipped-existing-runtime",
192
+ "skipped-existing-config",
193
+ "skipped-existing-env",
194
+ "skipped-existing-fallback",
195
+ "skipped-invalid",
196
+ ]);
188
197
 
189
198
  export const credentialUploadRequestSchema = z
190
199
  .object({
@@ -198,3 +207,11 @@ export const credentialUploadResponseSchema = z
198
207
  entries: z.array(credentialSnapshotEntrySchema),
199
208
  })
200
209
  .strict();
210
+
211
+ export const credentialIfAbsentUploadResponseSchema = z
212
+ .object({
213
+ inserted: z.boolean(),
214
+ reason: credentialIfAbsentReasonSchema,
215
+ entries: z.array(credentialSnapshotEntrySchema),
216
+ })
217
+ .strict();
@@ -151,6 +151,29 @@ export interface AuthCredentialSnapshotEntry {
151
151
  identityKey: string | null;
152
152
  }
153
153
 
154
+ export type AuthCredentialIfAbsentReason =
155
+ | "inserted"
156
+ | "skipped-existing"
157
+ | "skipped-existing-runtime"
158
+ | "skipped-existing-config"
159
+ | "skipped-existing-env"
160
+ | "skipped-existing-fallback"
161
+ | "skipped-invalid";
162
+
163
+ export interface AuthCredentialIfAbsentResult {
164
+ inserted: boolean;
165
+ reason: AuthCredentialIfAbsentReason;
166
+ provider: string;
167
+ entries: StoredAuthCredential[];
168
+ }
169
+
170
+ export interface AuthCredentialIfAbsentSnapshotResult {
171
+ inserted: boolean;
172
+ reason: AuthCredentialIfAbsentReason;
173
+ provider: string;
174
+ entries: AuthCredentialSnapshotEntry[];
175
+ }
176
+
154
177
  /**
155
178
  * Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and
156
179
  * served by the auth-broker server on `GET /v1/snapshot`.
@@ -182,6 +205,7 @@ export interface AuthCredentialStore {
182
205
  tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
183
206
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
184
207
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
208
+ upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
185
209
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
186
210
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
187
211
  setCache(key: string, value: string, expiresAtSec: number): void;
@@ -254,6 +278,10 @@ export interface AuthCredentialStore {
254
278
  * post-write read path is consistent.
255
279
  */
256
280
  upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
281
+ upsertAuthCredentialRemoteIfAbsent?(
282
+ provider: string,
283
+ credential: AuthCredential,
284
+ ): Promise<AuthCredentialIfAbsentResult>;
257
285
  /**
258
286
  * Optional async write hook for replace-all semantics (e.g. API-key login
259
287
  * overwriting any previous keys for the same provider). When present,
@@ -1234,6 +1262,56 @@ export class AuthStorage {
1234
1262
  this.#resetProviderAssignments(provider);
1235
1263
  }
1236
1264
 
1265
+ #toSnapshotEntries(provider: string, stored: StoredAuthCredential[]): AuthCredentialSnapshotEntry[] {
1266
+ return stored.map(entry => {
1267
+ const persisted = entry.credential;
1268
+ const redacted: SnapshotCredential =
1269
+ persisted.type === "api_key" ? persisted : { ...persisted, refresh: REMOTE_REFRESH_SENTINEL };
1270
+ return {
1271
+ id: entry.id,
1272
+ provider: entry.provider,
1273
+ credential: redacted,
1274
+ identityKey: resolveCredentialIdentityKey(provider, persisted),
1275
+ };
1276
+ });
1277
+ }
1278
+
1279
+ #snapshotSkipResult(provider: string, reason: AuthCredentialIfAbsentReason): AuthCredentialIfAbsentSnapshotResult {
1280
+ return {
1281
+ inserted: false,
1282
+ reason,
1283
+ provider,
1284
+ entries: this.exportSnapshot().credentials.filter(entry => entry.provider === provider),
1285
+ };
1286
+ }
1287
+
1288
+ async importCredentialIfAbsent(
1289
+ provider: string,
1290
+ credential: AuthCredential,
1291
+ ): Promise<AuthCredentialIfAbsentSnapshotResult> {
1292
+ if (this.#runtimeOverrides.has(provider)) return this.#snapshotSkipResult(provider, "skipped-existing-runtime");
1293
+ if (this.#configOverrides.has(provider)) return this.#snapshotSkipResult(provider, "skipped-existing-config");
1294
+ if (this.#getCredentialsForProvider(provider).length > 0)
1295
+ return this.#snapshotSkipResult(provider, "skipped-existing");
1296
+ if (getEnvApiKey(provider)) return this.#snapshotSkipResult(provider, "skipped-existing-env");
1297
+ if (this.#fallbackResolver?.(provider)) return this.#snapshotSkipResult(provider, "skipped-existing-fallback");
1298
+
1299
+ const result = this.#store.upsertAuthCredentialRemoteIfAbsent
1300
+ ? await this.#store.upsertAuthCredentialRemoteIfAbsent(provider, credential)
1301
+ : this.#store.upsertAuthCredentialForProviderIfAbsent(provider, credential);
1302
+ this.#setStoredCredentials(
1303
+ provider,
1304
+ result.entries.map(entry => ({ id: entry.id, credential: entry.credential })),
1305
+ );
1306
+ this.#resetProviderAssignments(provider);
1307
+ return {
1308
+ inserted: result.inserted,
1309
+ reason: result.reason,
1310
+ provider: result.provider,
1311
+ entries: this.#toSnapshotEntries(provider, result.entries),
1312
+ };
1313
+ }
1314
+
1237
1315
  async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
1238
1316
  const stored = this.#store.upsertAuthCredentialRemote
1239
1317
  ? await this.#store.upsertAuthCredentialRemote(provider, credential)
@@ -1515,6 +1593,14 @@ export class AuthStorage {
1515
1593
  });
1516
1594
  break;
1517
1595
  }
1596
+ case "glm-zcode": {
1597
+ const { loginGlmZcode } = await import("./utils/oauth/glm-zcode");
1598
+ credentials = await loginGlmZcode({
1599
+ ...ctrl,
1600
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1601
+ });
1602
+ break;
1603
+ }
1518
1604
  case "fireworks": {
1519
1605
  const { loginFireworks } = await import("./utils/oauth/fireworks");
1520
1606
  const apiKey = await loginFireworks(ctrl);
@@ -3343,17 +3429,7 @@ export class AuthStorage {
3343
3429
  stored.map(entry => ({ id: entry.id, credential: entry.credential })),
3344
3430
  );
3345
3431
  this.#resetProviderAssignments(provider);
3346
- return stored.map(entry => {
3347
- const persisted = entry.credential;
3348
- const redacted: SnapshotCredential =
3349
- persisted.type === "api_key" ? persisted : { ...persisted, refresh: REMOTE_REFRESH_SENTINEL };
3350
- return {
3351
- id: entry.id,
3352
- provider: entry.provider,
3353
- credential: redacted,
3354
- identityKey: resolveCredentialIdentityKey(provider, persisted),
3355
- };
3356
- });
3432
+ return this.#toSnapshotEntries(provider, stored);
3357
3433
  }
3358
3434
 
3359
3435
  /**
@@ -3989,6 +4065,48 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
3989
4065
  return result;
3990
4066
  }
3991
4067
 
4068
+ upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult {
4069
+ let serialized: SerializedCredentialRecord | null;
4070
+ try {
4071
+ serialized = serializeCredential(provider, credential);
4072
+ } catch {
4073
+ serialized = null;
4074
+ }
4075
+ if (!serialized) {
4076
+ return {
4077
+ inserted: false,
4078
+ reason: "skipped-invalid",
4079
+ provider,
4080
+ entries: this.listAuthCredentials(provider),
4081
+ };
4082
+ }
4083
+
4084
+ const writeIfAbsent = this.#db.transaction(
4085
+ (providerName: string, record: SerializedCredentialRecord): AuthCredentialIfAbsentResult => {
4086
+ const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
4087
+ const existing: StoredAuthCredential[] = [];
4088
+ for (const row of existingRows) {
4089
+ const activeCredential = deserializeCredential(row);
4090
+ if (!activeCredential) continue;
4091
+ existing.push(toStoredAuthCredential(row, activeCredential));
4092
+ }
4093
+ if (existing.length > 0) {
4094
+ return { inserted: false, reason: "skipped-existing", provider: providerName, entries: existing };
4095
+ }
4096
+
4097
+ this.#insertStmt.get(providerName, record.credentialType, record.data, record.identityKey);
4098
+ return {
4099
+ inserted: true,
4100
+ reason: "inserted",
4101
+ provider: providerName,
4102
+ entries: this.listAuthCredentials(providerName),
4103
+ };
4104
+ },
4105
+ );
4106
+
4107
+ return writeIfAbsent.immediate(provider, serialized);
4108
+ }
4109
+
3992
4110
  /**
3993
4111
  * Hard-deletes disabled rows for a provider when an active row with the same identity exists.
3994
4112
  * This prevents unbounded accumulation of soft-deleted credentials while preserving
package/src/cli.ts CHANGED
@@ -109,6 +109,7 @@ Providers:
109
109
  kagi Kagi
110
110
  tavily Tavily
111
111
  zai Z.AI (GLM Coding Plan)
112
+ glm-zcode GLM ZCode OAuth (unofficial, opt-in; at your own risk)
112
113
  deepseek DeepSeek
113
114
  xai xAI
114
115
  nanogpt NanoGPT