jeo-code 0.1.0 → 0.4.5

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.
Files changed (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +808 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +624 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -1,3 +1,4 @@
1
+ import { providerRegistry } from "./provider-registry";
1
2
  import { OAUTH_FLOW_REGISTRY } from "../auth/flows";
2
3
  import { readGlobalConfig } from "../agent/state";
3
4
  import { resolveCredential, type AuthProvider, type Credential } from "../auth";
@@ -5,18 +6,23 @@ import { anthropicAdapter } from "./providers/anthropic";
5
6
  import { openaiAdapter } from "./providers/openai";
6
7
  import { geminiAdapter } from "./providers/gemini";
7
8
  import { ollamaAdapter } from "./providers/ollama";
9
+ import { antigravityAdapter } from "./providers/antigravity";
8
10
  import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
9
11
  import { expandAlias, resolveModelId, effectiveAliasesFor } from "./model-registry";
10
12
  import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
13
+ import { toProviderModel, CODEX_MODELS } from "./model-catalog";
11
14
  import { withRetry, defaultRetryable, type RetryOptions } from "../util/retry";
15
+ import { jeoEnv } from "../util/env";
12
16
  import type { Config } from "../agent/state";
13
17
 
14
- const ADAPTERS: Record<ProviderName, ProviderAdapter> = {
15
- anthropic: anthropicAdapter,
16
- openai: openaiAdapter,
17
- gemini: geminiAdapter,
18
- ollama: ollamaAdapter,
19
- };
18
+
19
+ // Initialize Provider Registry
20
+ providerRegistry.register("anthropic", anthropicAdapter);
21
+ providerRegistry.register("openai", openaiAdapter);
22
+ providerRegistry.register("gemini", geminiAdapter);
23
+ providerRegistry.register("antigravity", antigravityAdapter);
24
+ providerRegistry.register("ollama", ollamaAdapter);
25
+
20
26
 
21
27
  export function resolveProvider(model: string): ProviderName {
22
28
  // Catalog is authoritative for known ids (correct even when heuristics would
@@ -25,11 +31,50 @@ export function resolveProvider(model: string): ProviderName {
25
31
  if (entry) return entry.provider;
26
32
  const m = (model ?? "").toLowerCase();
27
33
  if (m.startsWith("ollama/")) return "ollama";
34
+ if (m.startsWith("antigravity/")) return "antigravity";
28
35
  // OpenAI: explicit prefix, any GPT, or a reasoning model (o1/o3/o4-mini, o1-preview…).
29
36
  if (m.startsWith("openai/") || m.includes("gpt") || /(^|\/)o\d/.test(m)) return "openai";
30
37
  if (m.startsWith("google/") || m.includes("gemini")) return "gemini";
31
38
  return "anthropic";
32
39
  }
40
+ const PROVIDER_ID_PREFIX: Record<ProviderName, string> = {
41
+ anthropic: "anthropic/",
42
+ openai: "openai/",
43
+ gemini: "google/",
44
+ antigravity: "antigravity/",
45
+ ollama: "ollama/",
46
+ };
47
+
48
+ /**
49
+ * Pin-time provider qualification: when a picked live model id would route to a
50
+ * DIFFERENT provider than the list it came from (e.g. ollama's `qwen2.5:0.5b` → anthropic,
51
+ * ollama's `gpt-oss:20b` → openai), prefix it so resolveProvider routes correctly.
52
+ * Adapters strip these prefixes on the wire. Ids that already route correctly
53
+ * (catalog ids, aliases, prefixed ids) pass through unchanged.
54
+ */
55
+ export function qualifyModelId(model: string, provider: ProviderName): string {
56
+ const id = (model ?? "").trim();
57
+ if (!id) return id;
58
+ return resolveProvider(id) === provider ? id : `${PROVIDER_ID_PREFIX[provider]}${id}`;
59
+ }
60
+
61
+ /**
62
+ * Wire id for a (possibly provider-qualified) model id: a catalog canonical maps
63
+ * to the exact provider id (claude-sonnet-4-5 → claude-sonnet-4-5-20250929);
64
+ * live/provider/prefixed ids pass through unchanged (adapters strip prefixes).
65
+ */
66
+ export function providerModelFor(model: string): string {
67
+ if (
68
+ model.startsWith("ollama/") ||
69
+ model.startsWith("openai/") ||
70
+ model.startsWith("anthropic/") ||
71
+ model.startsWith("google/") ||
72
+ model.startsWith("antigravity/")
73
+ ) {
74
+ return model;
75
+ }
76
+ return toProviderModel(model, resolveProvider(model));
77
+ }
33
78
 
34
79
  /** Map the configured thinking level to a default max-token budget. */
35
80
  export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high" | "xhigh"): number {
@@ -40,6 +85,17 @@ export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high"
40
85
  return 4000;
41
86
  }
42
87
 
88
+ /** Map the thinking level to an OpenAI reasoning-effort tier. `minimal` maps to `low`
89
+ * (the lowest tier o-series reliably accepts; gpt-5's `minimal` is opt-in via options). */
90
+ export function thinkingToReasoningEffort(
91
+ level?: "minimal" | "low" | "medium" | "high" | "xhigh",
92
+ ): "low" | "medium" | "high" | undefined {
93
+ if (!level) return undefined;
94
+ if (level === "minimal" || level === "low") return "low";
95
+ if (level === "high" || level === "xhigh") return "high";
96
+ return "medium";
97
+ }
98
+
43
99
  /** Describe a model id: alias expansion + the provider it routes to. For `/model` + diagnostics. */
44
100
  export async function describeModel(input: string): Promise<{ input: string; resolved: string; provider: ProviderName }> {
45
101
  const resolved = await resolveModelId(input);
@@ -85,25 +141,123 @@ export interface ModelManager {
85
141
  resolveProvider: typeof resolveProvider;
86
142
  }
87
143
 
88
- const ALIAS_DEFAULTS = { fast: "ollama/qwen2.5:0.5b", local: "ollama/qwen2.5:0.5b", sonnet: "claude-3-5-sonnet", gpt: "gpt-4o", flash: "gemini-2.5-flash" };
144
+ const ALIAS_DEFAULTS = { fast: "ollama/qwen2.5:0.5b", local: "ollama/qwen2.5:0.5b", sonnet: "claude-sonnet-4-5", opus: "claude-opus-4-5", haiku: "claude-haiku-4-5", gpt: "gpt-5.5", flash: "gemini-2.5-flash" };
89
145
 
90
146
  /**
91
147
  * Build retry options from a config `retry` budget (gjc parity). `requestMaxRetries`
92
148
  * counts retries (not the initial request), so total `withRetry` attempts =
93
- * requestMaxRetries + 1. When unset, the `withRetry` defaults apply (3 attempts).
94
- * `maxDelayMs` caps backoff when provided.
149
+ * requestMaxRetries + 1. When unset, the `withRetry` defaults apply (3 attempts),
150
+ * but rate-limit (429) errors get a more generous budget + a backoff floor so a
151
+ * transient per-minute window can clear instead of the very first 429 instantly
152
+ * exhausting auto-retry. A server-directed retry delay above the five-minute
153
+ * budget is surfaced immediately with its reset hint instead of being capped and
154
+ * retried pointlessly. Explicit config (`requestMaxRetries`/`maxDelayMs`) always
155
+ * wins and disables the matching rate-limit default.
156
+ * `maxDelayMs` caps per-attempt backoff when provided.
95
157
  */
96
- export function resolveRetryOptions(retry: Config["retry"]): RetryOptions {
158
+ const DEFAULT_RATE_LIMIT_RETRIES = 6; // total attempts for 429 (initial + 5 retries)
159
+ // 429 floor when the server sends no Retry-After. Escalates per attempt inside
160
+ // withRetry (2s → 4s → 8s → 16s → 30s ≈ 60s total), spanning a per-minute window.
161
+ const DEFAULT_RATE_LIMIT_MIN_DELAY_MS = 2000;
162
+ // GJC parity for server-directed 429s: retry short windows, but do not hang a CLI
163
+ // through long subscription/account resets.
164
+ const DEFAULT_RATE_LIMIT_MAX_SERVER_DELAY_MS = 5 * 60 * 1000;
165
+ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "stream" = "request"): RetryOptions {
97
166
  const opts: RetryOptions = { isRetryable: defaultRetryable };
98
- if (typeof retry?.requestMaxRetries === "number") {
99
- opts.retries = retry.requestMaxRetries + 1;
167
+
168
+ let targetRetries: number | undefined;
169
+ if (kind === "request") {
170
+ if (typeof retry?.requestMaxRetries === "number") {
171
+ targetRetries = retry.requestMaxRetries;
172
+ } else if (typeof retry?.maxRetries === "number") {
173
+ targetRetries = retry.maxRetries;
174
+ }
175
+ } else if (kind === "stream") {
176
+ if (typeof retry?.streamMaxRetries === "number") {
177
+ targetRetries = retry.streamMaxRetries;
178
+ } else if (typeof retry?.maxRetries === "number") {
179
+ targetRetries = retry.maxRetries;
180
+ }
181
+ }
182
+
183
+ if (typeof targetRetries === "number") {
184
+ opts.retries = targetRetries + 1;
100
185
  }
101
- if (typeof retry?.maxDelayMs === "number") {
102
- opts.maxDelayMs = retry.maxDelayMs;
186
+
187
+ if (typeof retry?.maxDelayMs === "number") opts.maxDelayMs = retry.maxDelayMs;
188
+
189
+ // 429 attempt budget: explicit rateLimitRetries wins; else mirror the resolved
190
+ // budget (no bonus); else the generous default so a transient window can clear.
191
+ if (typeof retry?.rateLimitRetries === "number") {
192
+ opts.rateLimitRetries = retry.rateLimitRetries + 1;
193
+ } else if (typeof targetRetries === "number") {
194
+ opts.rateLimitRetries = targetRetries + 1;
195
+ } else {
196
+ opts.rateLimitRetries = DEFAULT_RATE_LIMIT_RETRIES;
197
+ }
198
+
199
+ // 429 backoff floor: explicit wins; else default UNLESS the user pinned maxDelayMs.
200
+ if (typeof retry?.rateLimitMinDelayMs === "number") opts.rateLimitMinDelayMs = retry.rateLimitMinDelayMs;
201
+ else if (typeof retry?.maxDelayMs !== "number") opts.rateLimitMinDelayMs = DEFAULT_RATE_LIMIT_MIN_DELAY_MS;
202
+ opts.rateLimitMaxServerDelayMs = DEFAULT_RATE_LIMIT_MAX_SERVER_DELAY_MS;
203
+
204
+ // Config-driven fail-fast overrides: a status in `failFastStatuses` or a message
205
+ // matching any `failFastPattern` is forced non-retryable, layered on top of the
206
+ // chosen predicate (which still decides everything else). gjc parity for pinning a
207
+ // normally-transient class (e.g. 503) to abort instead of riding the backoff ladder.
208
+ const failFastStatuses = retry?.failFastStatuses;
209
+ const failFastPatterns = retry?.failFastPatterns;
210
+ if ((failFastStatuses && failFastStatuses.length > 0) || (failFastPatterns && failFastPatterns.length > 0)) {
211
+ const base = opts.isRetryable ?? defaultRetryable;
212
+ const statusSet = new Set(failFastStatuses ?? []);
213
+ const lowered = (failFastPatterns ?? []).map(p => p.toLowerCase());
214
+ opts.isRetryable = (err: unknown, attempt: number): boolean => {
215
+ if (err && typeof err === "object") {
216
+ const raw = (err as { status?: unknown }).status;
217
+ const status = typeof raw === "number" ? raw : (typeof raw === "string" ? Number(raw) : NaN);
218
+ if (!Number.isNaN(status) && statusSet.has(status)) return false;
219
+ }
220
+ if (lowered.length > 0) {
221
+ const msg = err instanceof Error
222
+ ? err.message
223
+ : (typeof err === "object" && err !== null && typeof (err as { message?: unknown }).message === "string"
224
+ ? (err as { message: string }).message
225
+ : String(err));
226
+ const lowerMsg = msg.toLowerCase();
227
+ if (lowered.some(p => lowerMsg.includes(p))) return false;
228
+ }
229
+ return base(err, attempt);
230
+ };
103
231
  }
232
+
104
233
  return opts;
105
234
  }
106
235
 
236
+ /**
237
+ * Pick the credential to actually use for a provider call / live discovery.
238
+ * An API key is the broader, documented path, so it wins whenever present.
239
+ * Every bundled OAuth flow is now served end-to-end (Anthropic Messages,
240
+ * OpenAI ChatGPT/Codex Responses, Gemini/Antigravity Cloud Code Assist); the
241
+ * guard below only fires for a future flow that ships before its adapter.
242
+ */
243
+ export function effectiveCredentialForProvider(
244
+ provider: AuthProvider,
245
+ credential: Credential,
246
+ config: Pick<Config, "providers">,
247
+ model: string,
248
+ ): Credential {
249
+ if (credential.kind === "oauth") {
250
+ const apiKey = config.providers[provider];
251
+ if (apiKey) return { kind: "api_key", provider, token: apiKey };
252
+ if (OAUTH_FLOW_REGISTRY[provider]?.verifiedEndToEnd === false) {
253
+ throw new Error(
254
+ `Provider '${provider}' has only an OAuth token, but its OAuth backend is not compatible with the bundled adapter. Set ${provider.toUpperCase()}_API_KEY (or run 'jeo setup') to use ${model}.`,
255
+ );
256
+ }
257
+ }
258
+ return credential;
259
+ }
260
+
107
261
  interface Resolved {
108
262
  adapter: ProviderAdapter;
109
263
  callOptions: CallOptions;
@@ -111,12 +265,34 @@ interface Resolved {
111
265
  retry: RetryOptions;
112
266
  }
113
267
 
114
- async function resolveCall(options: Partial<CallOptions>): Promise<Resolved> {
268
+ /**
269
+ * The credential to actually use for a provider call. A configured local OpenAI-compatible base
270
+ * URL must use the standard /chat/completions path, but the openai adapter dispatches on
271
+ * `credential.kind === "oauth"` → the hardcoded Codex backend, which drops the base URL. So when a
272
+ * base URL is set we downgrade an OAuth credential to the configured api key, else keyless — making
273
+ * discovery (which honors the base URL) and execution agree. All other cases pass through unchanged.
274
+ */
275
+ export function credentialForCall(
276
+ provider: ProviderName,
277
+ effective: Credential,
278
+ config: Pick<Config, "providers">,
279
+ baseUrl: string | undefined,
280
+ ): Credential {
281
+ const isLocalOpenAi = provider === "openai" && !!baseUrl;
282
+ if (isLocalOpenAi && effective.kind === "oauth") {
283
+ return config.providers.openai
284
+ ? { kind: "api_key", provider: "openai", token: config.providers.openai }
285
+ : { kind: "none", provider: "openai" };
286
+ }
287
+ return effective;
288
+ }
289
+
290
+ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "stream" = "request"): Promise<Resolved> {
115
291
  const config = await readGlobalConfig();
116
292
  const aliases = { ...((config as { modelAliases?: Record<string, string> }).modelAliases ?? {}) };
117
293
  const model = expandAlias(options.model ?? config.defaultModel, { ...ALIAS_DEFAULTS, ...aliases });
118
294
  const provider = resolveProvider(model);
119
- const adapter = ADAPTERS[provider];
295
+ const adapter = providerRegistry.get(provider)!;
120
296
 
121
297
  const baseUrl =
122
298
  options.baseUrl ??
@@ -124,7 +300,9 @@ async function resolveCall(options: Partial<CallOptions>): Promise<Resolved> {
124
300
  (provider === "ollama" ? config.ollamaBaseUrl : undefined);
125
301
 
126
302
  const callOptions: CallOptions = {
127
- model,
303
+ // Map a catalog canonical (e.g. claude-3-5-sonnet) to the exact wire id the
304
+ // provider accepts (claude-3-5-sonnet-20241022); live/provider ids pass through.
305
+ model: providerModelFor(model),
128
306
  systemPrompt: options.systemPrompt,
129
307
  temperature: options.temperature ?? 0.2,
130
308
  maxTokens: options.maxTokens ?? thinkingMaxTokens(config.thinkingLevel),
@@ -132,30 +310,149 @@ async function resolveCall(options: Partial<CallOptions>): Promise<Resolved> {
132
310
  baseUrl,
133
311
  onUsage: options.onUsage,
134
312
  signal: options.signal,
313
+ reasoningEffort: options.reasoningEffort ?? thinkingToReasoningEffort(config.thinkingLevel),
135
314
  };
315
+ // Caller-supplied retry sink rides on the config-derived retry budget so the
316
+ // engine/TUI can surface "rate limited — retrying in Ns" instead of a silent wait.
317
+ // gjc parity: `requestMaxRetries` governs non-stream calls; `streamMaxRetries`
318
+ // governs the stream site's replay-safe pre-first-chunk loop (retryableStream
319
+ // never replays after the first emitted chunk). Both fall back to `maxRetries`,
320
+ // and an unset stream budget keeps the conservative withRetry default — the
321
+ // generous gjc default of 100 only applies when the user configures it.
322
+ const retry: RetryOptions = { ...resolveRetryOptions(config.retry, kind), ...(options.onRetry ? { onRetry: options.onRetry } : {}) };
136
323
 
137
324
  if (provider === "ollama") {
138
- return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry: resolveRetryOptions(config.retry) };
325
+ return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry };
139
326
  }
140
327
 
141
- const credential = await resolveCredential(provider as AuthProvider);
142
- let effective = credential;
143
- if (effective.kind === "oauth" && OAUTH_FLOW_REGISTRY[provider as AuthProvider]?.verifiedEndToEnd === false) {
144
- const apiKey = config.providers[provider as AuthProvider];
145
- if (apiKey) {
146
- effective = { kind: "api_key", provider: provider as AuthProvider, token: apiKey };
147
- } else {
148
- throw new Error(`Provider '${provider}' has only an OAuth token, but its OAuth backend is not compatible with the bundled adapter. Set ${provider.toUpperCase()}_API_KEY (or run 'joc setup') to use ${model}.`);
328
+ if (provider === "antigravity") {
329
+ // Prefer the dedicated Antigravity login (its client is what the agent
330
+ // backend authorizes); fall back to a gemini-cli OAuth token for users with
331
+ // their own project/permissions.
332
+ let credential = await resolveCredential("antigravity");
333
+ if (credential.kind !== "oauth") credential = await resolveCredential("gemini");
334
+ if (credential.kind !== "oauth") {
335
+ throw new Error("Antigravity models use Google OAuth. Run 'jeo auth login antigravity' (recommended) or 'jeo auth login gemini', then retry the Google Cloud projectId is discovered automatically.");
149
336
  }
337
+ return { adapter, callOptions, credential, retry };
150
338
  }
151
339
 
340
+ const credentialProvider = provider as AuthProvider;
341
+ const credential = await resolveCredential(credentialProvider);
342
+ const effective = effectiveCredentialForProvider(credentialProvider, credential, config, model);
152
343
  const isLocalOpenAi = provider === "openai" && !!baseUrl;
344
+ if (provider === "openai" && effective.kind === "oauth" && !isLocalOpenAi && !CODEX_MODELS.includes(model)) {
345
+ throw new Error(
346
+ "OpenAI OAuth 자격증명은 Codex 모델(gpt-5.5/gpt-5.4)만 지원. OPENAI_API_KEY를 설정하거나 모델을 변경하세요"
347
+ );
348
+ }
153
349
  if (effective.kind === "none" && !isLocalOpenAi) {
154
350
  throw new Error(
155
- `No credential for provider '${provider}'. Run 'joc setup', 'joc auth login', or set ${provider.toUpperCase()}_API_KEY / ${provider.toUpperCase()}_OAUTH_TOKEN.`
351
+ `No credential for provider '${provider}'. Run 'jeo setup', 'jeo auth login', or set ${provider.toUpperCase()}_API_KEY / ${provider.toUpperCase()}_OAUTH_TOKEN.`
156
352
  );
157
353
  }
158
- return { adapter, callOptions, credential: effective, retry: resolveRetryOptions(config.retry) };
354
+ return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry };
355
+ }
356
+
357
+ /** Hard cap for a single non-streaming provider request (service-readiness: a
358
+ * blackholed/unreachable provider must not hang the agent or `jeo team`). */
359
+ const DEFAULT_CALL_TIMEOUT_MS = 120_000;
360
+
361
+ /** Per-chunk idle cap for streaming: a stream that emits NOTHING for this long is
362
+ * aborted, but a healthy long generation (chunks keep arriving) runs unbounded —
363
+ * unlike a single wall-clock cap that would kill a long-but-active stream. */
364
+ const STREAM_IDLE_TIMEOUT_MS = 120_000;
365
+
366
+ /** Combine two abort signals into one. Preserves BOTH even when `AbortSignal.any`
367
+ * is unavailable (manual fallback), so neither the caller's cancel nor the timeout
368
+ * is silently dropped. */
369
+ function composeAbort(a: AbortSignal | undefined, b: AbortSignal): AbortSignal {
370
+ if (!a) return b;
371
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
372
+ if (a.aborted || b.aborted) return AbortSignal.abort();
373
+ const ctrl = new AbortController();
374
+ // Memory hygiene: `a` is typically the TURN-long abort signal — a once-listener
375
+ // per model call would otherwise accumulate on it for the whole turn. Detach
376
+ // BOTH listeners as soon as either side fires.
377
+ const onAbort = () => {
378
+ a.removeEventListener("abort", onAbort);
379
+ b.removeEventListener("abort", onAbort);
380
+ ctrl.abort();
381
+ };
382
+ a.addEventListener("abort", onAbort, { once: true });
383
+ b.addEventListener("abort", onAbort, { once: true });
384
+ return ctrl.signal;
385
+ }
386
+
387
+ /** Compose the caller's signal (if any) with a fresh per-attempt timeout. */
388
+ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
389
+ return composeAbort(signal, AbortSignal.timeout(ms));
390
+ }
391
+
392
+ /**
393
+ * Stream wrapper that retries ONLY the initial connection — before any chunk is
394
+ * yielded — so a transient 429/5xx on stream connect recovers (the non-streaming
395
+ * call path already retried; the stream path previously had no retry). A failure
396
+ * after the first token propagates (retrying would duplicate emitted output).
397
+ */
398
+ export interface StreamIdleOptions {
399
+ /** Abort + reject if no chunk arrives within this many ms (per-chunk, not total). */
400
+ idleMs: number;
401
+ /** Optional OVERALL wall-clock deadline (epoch ms) — round-14, architect #7.
402
+ * Default absent: per-chunk idle alone keeps long ACTIVE generations alive.
403
+ * Non-interactive contexts opt in (JEO_STREAM_MAX_MS) so a slow-drip stream
404
+ * (one token every idleMs-ε) cannot run unbounded. */
405
+ deadlineAt?: number;
406
+ onIdle?: () => void;
407
+ }
408
+
409
+ /** `iter.next()`, racing the per-chunk idle timeout AND (when set) the overall deadline. */
410
+ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptions): Promise<IteratorResult<string>> {
411
+ if (!idle) return iter.next();
412
+ const remaining = idle.deadlineAt !== undefined ? idle.deadlineAt - Date.now() : Infinity;
413
+ if (remaining <= 0) {
414
+ idle.onIdle?.();
415
+ throw new Error(`stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted`);
416
+ }
417
+ const waitMs = Math.min(idle.idleMs, remaining);
418
+ const deadlineFires = remaining < idle.idleMs;
419
+ let timer: ReturnType<typeof setTimeout> | undefined;
420
+ const timeout = new Promise<never>((_, reject) => {
421
+ timer = setTimeout(() => {
422
+ idle.onIdle?.();
423
+ reject(new Error(deadlineFires
424
+ ? `stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted`
425
+ : `stream idle for ${idle.idleMs}ms (no chunk)`));
426
+ }, waitMs);
427
+ });
428
+ try {
429
+ return await Promise.race([iter.next(), timeout]);
430
+ } finally {
431
+ if (timer) clearTimeout(timer);
432
+ }
433
+ }
434
+
435
+ /** Opt-in overall stream wall-clock from the environment; undefined = off (default). */
436
+ export function streamMaxMs(env?: Record<string, string | undefined>): number | undefined {
437
+ const raw = jeoEnv("STREAM_MAX_MS", env);
438
+ const n = raw !== undefined ? parseInt(raw, 10) : NaN;
439
+ return Number.isFinite(n) && n > 0 ? n : undefined;
440
+ }
441
+
442
+ export async function* retryableStream(
443
+ makeIter: () => AsyncIterator<string>,
444
+ retry: RetryOptions,
445
+ idle?: StreamIdleOptions,
446
+ ): AsyncGenerator<string> {
447
+ const { iter, first } = await withRetry(async () => {
448
+ const it = makeIter();
449
+ const f = await nextMaybeIdle(it, idle);
450
+ return { iter: it, first: f };
451
+ }, retry);
452
+ if (!first.done) {
453
+ yield first.value;
454
+ for (let n = await nextMaybeIdle(iter, idle); !n.done; n = await nextMaybeIdle(iter, idle)) yield n.value;
455
+ }
159
456
  }
160
457
 
161
458
  export function createModelManager(): ModelManager {
@@ -163,15 +460,32 @@ export function createModelManager(): ModelManager {
163
460
  resolveProvider,
164
461
  async call(messages, options = {}) {
165
462
  const { adapter, callOptions, credential, retry } = await resolveCall(options);
166
- return withRetry(() => adapter.call(messages, callOptions, credential), retry);
463
+ return withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, DEFAULT_CALL_TIMEOUT_MS) }, credential), retry);
167
464
  },
168
465
  async *stream(messages, options = {}) {
169
- const { adapter, callOptions, credential, retry } = await resolveCall(options);
466
+ const { adapter, callOptions, credential, retry } = await resolveCall(options, "stream");
170
467
  if (adapter.stream) {
171
- yield* adapter.stream(messages, callOptions, credential);
468
+ const streamFn = adapter.stream.bind(adapter);
469
+ // Per-attempt abort controller fired by the idle timeout — so a stalled stream
470
+ // is cancelled, but a long, actively-emitting generation is NOT killed by a
471
+ // total wall-clock cap. The caller's signal (Ctrl-C) is preserved via composeAbort.
472
+ // JEO_STREAM_MAX_MS opts in to an OVERALL deadline (round-14): non-interactive
473
+ // runs can bound a slow-drip stream the per-chunk idle alone never catches.
474
+ let attempt: AbortController | null = null;
475
+ const makeIter = () => {
476
+ attempt = new AbortController();
477
+ const signal = composeAbort(callOptions.signal, attempt.signal);
478
+ return streamFn(messages, { ...callOptions, signal }, credential)[Symbol.asyncIterator]();
479
+ };
480
+ const maxMs = streamMaxMs();
481
+ yield* retryableStream(makeIter, retry, {
482
+ idleMs: STREAM_IDLE_TIMEOUT_MS,
483
+ ...(maxMs !== undefined ? { deadlineAt: Date.now() + maxMs } : {}),
484
+ onIdle: () => attempt?.abort(),
485
+ });
172
486
  } else {
173
487
  // Fallback: providers without streaming yield the full response as one chunk.
174
- yield await withRetry(() => adapter.call(messages, callOptions, credential), retry);
488
+ yield await withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, DEFAULT_CALL_TIMEOUT_MS) }, credential), retry);
175
489
  }
176
490
  },
177
491
  };
@@ -2,7 +2,7 @@
2
2
  * Model picker — turn a live discovery result set into a flat, 1-based pick list
3
3
  * so the TUI can select a model by number (`/model #3`) or by a fuzzy substring
4
4
  * (`/model gpt-4`). Pure functions over `ProviderModelsResult[]`, so they are
5
- * fully unit-testable and shared by `/model`, `/models`, and `/provider`.
5
+ * fully unit-testable and shared by `/model` and `/provider`.
6
6
  */
7
7
  import type { ProviderModelsResult } from "./model-discovery";
8
8
  import type { ProviderName } from "./types";
@@ -9,8 +9,10 @@ export interface ModelAliases {
9
9
  export const BUILTIN_ALIASES: ModelAliases = {
10
10
  fast: "ollama/qwen2.5:0.5b",
11
11
  local: "ollama/qwen2.5:0.5b",
12
- sonnet: "claude-3-5-sonnet",
13
- gpt: "gpt-4o",
12
+ sonnet: "claude-sonnet-4-5",
13
+ opus: "claude-opus-4-5",
14
+ haiku: "claude-haiku-4-5",
15
+ gpt: "gpt-5.5",
14
16
  flash: "gemini-2.5-flash",
15
17
  };
16
18
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Static per-model price table for live `$` cost accounting (consensus-seed P1.B3).
3
+ *
4
+ * Prices are USD per 1,000,000 tokens, split input/output, and are MAINTAINED MANUALLY
5
+ * here (no network lookup) — update against each provider's public pricing page. Matching
6
+ * is by model-family substring so versioned ids (e.g. `claude-sonnet-4-5-20250929`) resolve
7
+ * without an exact-id table. An UNKNOWN model returns `null` so the caller shows token
8
+ * counts only and never fabricates a dollar figure.
9
+ */
10
+ export interface ModelPrice {
11
+ /** USD per 1M input tokens. */
12
+ inPerM: number;
13
+ /** USD per 1M output tokens. */
14
+ outPerM: number;
15
+ }
16
+
17
+ export interface TokenUsage {
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ }
21
+
22
+ /**
23
+ * Family price table. Order matters: the FIRST substring that matches the lowercased
24
+ * model id wins, so list more-specific families before generic ones.
25
+ */
26
+ const PRICE_TABLE: ReadonlyArray<readonly [pattern: string, price: ModelPrice]> = [
27
+ // Anthropic Claude
28
+ ["claude-opus", { inPerM: 15, outPerM: 75 }],
29
+ ["claude-sonnet", { inPerM: 3, outPerM: 15 }],
30
+ ["claude-haiku", { inPerM: 0.8, outPerM: 4 }],
31
+ ["opus", { inPerM: 15, outPerM: 75 }],
32
+ ["sonnet", { inPerM: 3, outPerM: 15 }],
33
+ ["haiku", { inPerM: 0.8, outPerM: 4 }],
34
+ // OpenAI o-series (reasoning) — pricier; match before generic gpt
35
+ ["o3", { inPerM: 2, outPerM: 8 }],
36
+ ["o4", { inPerM: 2, outPerM: 8 }],
37
+ ["o1", { inPerM: 15, outPerM: 60 }],
38
+ // OpenAI GPT
39
+ ["gpt-5", { inPerM: 1.25, outPerM: 10 }],
40
+ ["gpt-4o-mini", { inPerM: 0.15, outPerM: 0.6 }],
41
+ ["gpt-4o", { inPerM: 2.5, outPerM: 10 }],
42
+ ["gpt-4", { inPerM: 2.5, outPerM: 10 }],
43
+ ["gpt", { inPerM: 1.25, outPerM: 10 }],
44
+ // Google Gemini
45
+ ["gemini-2.5-pro", { inPerM: 1.25, outPerM: 10 }],
46
+ ["gemini-1.5-pro", { inPerM: 1.25, outPerM: 5 }],
47
+ ["gemini-2.5-flash", { inPerM: 0.3, outPerM: 2.5 }],
48
+ ["gemini-2.0-flash", { inPerM: 0.1, outPerM: 0.4 }],
49
+ ["gemini", { inPerM: 0.3, outPerM: 2.5 }],
50
+ ];
51
+
52
+ /** Resolve the price for a model id by family substring, or `null` when unknown. */
53
+ export function priceForModel(model: string | undefined): ModelPrice | null {
54
+ if (!model) return null;
55
+ const id = model.toLowerCase();
56
+ // Strip a leading `provider/` qualifier (e.g. `ollama/qwen`, `antigravity/...`).
57
+ const bare = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
58
+ for (const [pattern, price] of PRICE_TABLE) {
59
+ if (bare.includes(pattern)) return price;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * USD cost for a turn's token usage on `model`, or `null` when the model has no known
66
+ * price (caller then shows tokens only). Local/keyless models (ollama/*) and unlisted
67
+ * families return null by design — there is no real dollar cost to display.
68
+ */
69
+ export function costForUsage(model: string | undefined, usage: TokenUsage | null | undefined): number | null {
70
+ if (!usage) return null;
71
+ const price = priceForModel(model);
72
+ if (!price) return null;
73
+ const cost = (usage.inputTokens / 1_000_000) * price.inPerM + (usage.outputTokens / 1_000_000) * price.outPerM;
74
+ return Number.isFinite(cost) ? cost : null;
75
+ }
76
+
77
+ /** Format a USD cost compactly: `$0.42`, `$1.20`, `$12.3`, `<$0.01` for tiny non-zero. */
78
+ export function formatCost(usd: number): string {
79
+ if (usd <= 0) return "$0.00";
80
+ if (usd < 0.01) return "<$0.01";
81
+ if (usd < 10) return `$${usd.toFixed(2)}`;
82
+ if (usd < 100) return `$${usd.toFixed(1)}`;
83
+ return `$${Math.round(usd)}`;
84
+ }
@@ -0,0 +1,23 @@
1
+ import type { ProviderAdapter, ProviderName } from "./types";
2
+
3
+ /**
4
+ * Provider Registry: Central hub for managing and loading LLM providers.
5
+ * Decouples model-manager from specific provider implementations.
6
+ */
7
+ class ProviderRegistry {
8
+ private adapters = new Map<ProviderName, ProviderAdapter>();
9
+
10
+ register(name: ProviderName, adapter: ProviderAdapter) {
11
+ this.adapters.set(name, adapter);
12
+ }
13
+
14
+ get(name: ProviderName): ProviderAdapter | undefined {
15
+ return this.adapters.get(name);
16
+ }
17
+
18
+ listProviders(): ProviderName[] {
19
+ return Array.from(this.adapters.keys());
20
+ }
21
+ }
22
+
23
+ export const providerRegistry = new ProviderRegistry();