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.
- package/README.ja.md +160 -0
- package/README.ko.md +160 -0
- package/README.md +115 -297
- package/README.zh.md +160 -0
- package/package.json +11 -6
- package/scripts/install.sh +28 -28
- package/scripts/uninstall.sh +17 -15
- package/src/AGENTS.md +50 -0
- package/src/agent/AGENTS.md +49 -0
- package/src/agent/bash-fixups.ts +103 -0
- package/src/agent/compaction.ts +410 -19
- package/src/agent/config-schema.ts +119 -5
- package/src/agent/context-files.ts +314 -17
- package/src/agent/dev/AGENTS.md +36 -0
- package/src/agent/dev/advanced-analyzer.ts +12 -0
- package/src/agent/dev/evolution-bridge.ts +82 -0
- package/src/agent/dev/evolution-logger.ts +41 -0
- package/src/agent/dev/self-analysis.ts +64 -0
- package/src/agent/dev/self-improve.ts +24 -0
- package/src/agent/dev/spec-automation.ts +49 -0
- package/src/agent/engine.ts +808 -54
- package/src/agent/hooks.ts +273 -0
- package/src/agent/loop.ts +21 -1
- package/src/agent/memory.ts +201 -0
- package/src/agent/model-recency.ts +32 -0
- package/src/agent/output-minimizer.ts +108 -0
- package/src/agent/output-util.ts +64 -0
- package/src/agent/plan.ts +187 -0
- package/src/agent/seed.ts +52 -0
- package/src/agent/session.ts +235 -21
- package/src/agent/state.ts +286 -39
- package/src/agent/step-budget.ts +232 -0
- package/src/agent/subagents.ts +223 -26
- package/src/agent/task-tool.ts +272 -0
- package/src/agent/todo-tool.ts +87 -0
- package/src/agent/tokenizer.ts +117 -0
- package/src/agent/tool-registry.ts +54 -0
- package/src/agent/tools.ts +624 -103
- package/src/agent/web-search.ts +538 -0
- package/src/ai/AGENTS.md +44 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog-compat.ts +3 -1
- package/src/ai/model-catalog.ts +74 -9
- package/src/ai/model-discovery.ts +215 -17
- package/src/ai/model-manager.ts +346 -32
- package/src/ai/model-picker.ts +1 -1
- package/src/ai/model-registry.ts +4 -2
- package/src/ai/pricing.ts +84 -0
- package/src/ai/provider-registry.ts +23 -0
- package/src/ai/provider-status.ts +60 -16
- package/src/ai/providers/AGENTS.md +42 -0
- package/src/ai/providers/anthropic.ts +250 -31
- package/src/ai/providers/antigravity.ts +219 -0
- package/src/ai/providers/errors.ts +15 -1
- package/src/ai/providers/gemini.ts +196 -13
- package/src/ai/providers/ollama.ts +37 -7
- package/src/ai/providers/openai-responses.ts +173 -0
- package/src/ai/providers/openai.ts +64 -12
- package/src/ai/sse.ts +4 -1
- package/src/ai/types.ts +18 -1
- package/src/auth/AGENTS.md +41 -0
- package/src/auth/callback-server.ts +6 -1
- package/src/auth/flows/AGENTS.md +32 -0
- package/src/auth/flows/antigravity.ts +151 -0
- package/src/auth/flows/google-project.ts +190 -0
- package/src/auth/flows/google.ts +39 -18
- package/src/auth/flows/index.ts +15 -5
- package/src/auth/flows/openai.ts +2 -2
- package/src/auth/oauth.ts +8 -0
- package/src/auth/refresh.ts +44 -27
- package/src/auth/storage.ts +149 -26
- package/src/auth/types.ts +1 -1
- package/src/autopilot.ts +362 -0
- package/src/bun-imports.d.ts +4 -0
- package/src/cli/AGENTS.md +39 -0
- package/src/cli/runner.ts +148 -14
- package/src/cli.ts +13 -4
- package/src/commands/AGENTS.md +40 -0
- package/src/commands/approve.ts +62 -3
- package/src/commands/auth.ts +167 -25
- package/src/commands/chat.ts +37 -8
- package/src/commands/deep-interview.ts +633 -175
- package/src/commands/doctor.ts +84 -37
- package/src/commands/evolve-core.ts +18 -0
- package/src/commands/evolve.ts +2 -1
- package/src/commands/export.ts +176 -0
- package/src/commands/gjc.ts +52 -0
- package/src/commands/launch.ts +3549 -240
- package/src/commands/mcp.ts +3 -3
- package/src/commands/ooo-seed.ts +19 -0
- package/src/commands/ralplan.ts +253 -35
- package/src/commands/resume.ts +1 -1
- package/src/commands/session.ts +183 -0
- package/src/commands/setup-helpers.ts +10 -3
- package/src/commands/setup.ts +57 -16
- package/src/commands/skills.ts +78 -18
- package/src/commands/state.ts +198 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/team.ts +340 -212
- package/src/commands/ultragoal.ts +122 -61
- package/src/commands/update.ts +244 -0
- package/src/ledger.ts +270 -0
- package/src/mcp/AGENTS.md +38 -0
- package/src/mcp/server.ts +115 -14
- package/src/mcp/tools.ts +42 -22
- package/src/md-modules.d.ts +4 -0
- package/src/prompts/AGENTS.md +41 -0
- package/src/prompts/agents/AGENTS.md +35 -0
- package/src/prompts/agents/architect.md +35 -0
- package/src/prompts/agents/critic.md +37 -0
- package/src/prompts/agents/executor.md +36 -0
- package/src/prompts/agents/planner.md +37 -0
- package/src/prompts/skills/AGENTS.md +36 -0
- package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
- package/src/prompts/skills/deep-dive/SKILL.md +13 -0
- package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
- package/src/prompts/skills/deep-interview/SKILL.md +12 -0
- package/src/prompts/skills/gjc/AGENTS.md +31 -0
- package/src/prompts/skills/gjc/SKILL.md +15 -0
- package/src/prompts/skills/ralplan/AGENTS.md +31 -0
- package/src/prompts/skills/ralplan/SKILL.md +11 -0
- package/src/prompts/skills/team/AGENTS.md +31 -0
- package/src/prompts/skills/team/SKILL.md +11 -0
- package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
- package/src/prompts/skills/ultragoal/SKILL.md +11 -0
- package/src/skills/AGENTS.md +38 -0
- package/src/skills/catalog.ts +565 -31
- package/src/tui/AGENTS.md +43 -0
- package/src/tui/app.ts +1181 -92
- package/src/tui/components/AGENTS.md +42 -0
- package/src/tui/components/ascii-art.ts +257 -15
- package/src/tui/components/autocomplete.ts +98 -16
- package/src/tui/components/autopilot-status.ts +65 -0
- package/src/tui/components/category-index.ts +49 -0
- package/src/tui/components/code-view.ts +54 -11
- package/src/tui/components/color.ts +171 -2
- package/src/tui/components/config-panel.ts +82 -15
- package/src/tui/components/duration.ts +38 -0
- package/src/tui/components/evolution.ts +3 -3
- package/src/tui/components/footer.ts +91 -42
- package/src/tui/components/forge.ts +426 -31
- package/src/tui/components/hints.ts +54 -0
- package/src/tui/components/hud.ts +73 -0
- package/src/tui/components/index.ts +4 -0
- package/src/tui/components/input-box.ts +150 -0
- package/src/tui/components/layout.ts +11 -3
- package/src/tui/components/live-model-picker.ts +108 -0
- package/src/tui/components/markdown-table.ts +140 -0
- package/src/tui/components/markdown-text.ts +97 -0
- package/src/tui/components/meter.ts +4 -1
- package/src/tui/components/model-picker.ts +3 -2
- package/src/tui/components/provider-picker.ts +3 -2
- package/src/tui/components/section.ts +70 -0
- package/src/tui/components/select-list.ts +40 -10
- package/src/tui/components/skill-picker.ts +25 -0
- package/src/tui/components/slash.ts +244 -21
- package/src/tui/components/status.ts +272 -11
- package/src/tui/components/step-timeline.ts +218 -0
- package/src/tui/components/stream.ts +26 -9
- package/src/tui/components/themes.ts +212 -6
- package/src/tui/components/todo-card.ts +47 -0
- package/src/tui/components/tool-list.ts +58 -12
- package/src/tui/components/transcript.ts +120 -0
- package/src/tui/components/update-box.ts +31 -0
- package/src/tui/components/welcome.ts +162 -0
- package/src/tui/components/width.ts +163 -0
- package/src/tui/monitoring/AGENTS.md +31 -0
- package/src/tui/monitoring/hud-view.ts +55 -0
- package/src/tui/renderer.ts +112 -3
- package/src/tui/terminal.ts +40 -33
- package/src/util/AGENTS.md +39 -0
- package/src/util/clipboard-image.ts +118 -0
- package/src/util/env.ts +12 -0
- package/src/util/provider-error.ts +78 -0
- package/src/util/retry.ts +91 -6
- package/src/util/update-check.ts +64 -0
- package/src/commands/models.ts +0 -104
package/src/ai/model-manager.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
325
|
+
return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry };
|
|
139
326
|
}
|
|
140
327
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
throw new Error(
|
|
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 '
|
|
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,
|
|
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
|
-
|
|
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
|
};
|
package/src/ai/model-picker.ts
CHANGED
|
@@ -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
|
|
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";
|
package/src/ai/model-registry.ts
CHANGED
|
@@ -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-
|
|
13
|
-
|
|
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();
|