jeo-code 0.1.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.
Files changed (93) hide show
  1. package/README.md +342 -0
  2. package/package.json +57 -0
  3. package/scripts/install.sh +322 -0
  4. package/scripts/uninstall.sh +30 -0
  5. package/src/agent/compaction.ts +75 -0
  6. package/src/agent/config-schema.ts +87 -0
  7. package/src/agent/context-files.ts +51 -0
  8. package/src/agent/engine.ts +208 -0
  9. package/src/agent/json.ts +87 -0
  10. package/src/agent/loop.ts +22 -0
  11. package/src/agent/session.ts +198 -0
  12. package/src/agent/state.ts +199 -0
  13. package/src/agent/subagents.ts +149 -0
  14. package/src/agent/tools.ts +355 -0
  15. package/src/ai/index.ts +11 -0
  16. package/src/ai/model-catalog-compat.ts +119 -0
  17. package/src/ai/model-catalog.ts +97 -0
  18. package/src/ai/model-discovery.ts +148 -0
  19. package/src/ai/model-enrich.ts +75 -0
  20. package/src/ai/model-manager.ts +178 -0
  21. package/src/ai/model-picker.ts +73 -0
  22. package/src/ai/model-registry.ts +83 -0
  23. package/src/ai/provider-status.ts +77 -0
  24. package/src/ai/providers/anthropic.ts +87 -0
  25. package/src/ai/providers/errors.ts +47 -0
  26. package/src/ai/providers/gemini.ts +77 -0
  27. package/src/ai/providers/ollama.ts +54 -0
  28. package/src/ai/providers/openai.ts +67 -0
  29. package/src/ai/sse.ts +46 -0
  30. package/src/ai/types.ts +37 -0
  31. package/src/auth/callback-server.ts +195 -0
  32. package/src/auth/flows/anthropic.ts +114 -0
  33. package/src/auth/flows/google.ts +120 -0
  34. package/src/auth/flows/index.ts +50 -0
  35. package/src/auth/flows/openai.ts +130 -0
  36. package/src/auth/index.ts +23 -0
  37. package/src/auth/oauth.ts +80 -0
  38. package/src/auth/pkce.ts +24 -0
  39. package/src/auth/refresh.ts +60 -0
  40. package/src/auth/storage.ts +113 -0
  41. package/src/auth/types.ts +26 -0
  42. package/src/cli/index.ts +1 -0
  43. package/src/cli/runner.ts +245 -0
  44. package/src/cli.ts +17 -0
  45. package/src/commands/approve.ts +63 -0
  46. package/src/commands/auth.ts +144 -0
  47. package/src/commands/chat.ts +37 -0
  48. package/src/commands/deep-interview.ts +239 -0
  49. package/src/commands/doctor.ts +250 -0
  50. package/src/commands/evolve.ts +191 -0
  51. package/src/commands/launch.ts +745 -0
  52. package/src/commands/mcp.ts +18 -0
  53. package/src/commands/models.ts +104 -0
  54. package/src/commands/ralplan.ts +86 -0
  55. package/src/commands/resume.ts +6 -0
  56. package/src/commands/setup-helpers.ts +93 -0
  57. package/src/commands/setup.ts +190 -0
  58. package/src/commands/skills.ts +38 -0
  59. package/src/commands/team.ts +337 -0
  60. package/src/commands/ultragoal.ts +102 -0
  61. package/src/index.ts +31 -0
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp/protocol.ts +45 -0
  64. package/src/mcp/server.ts +97 -0
  65. package/src/mcp/tools.ts +156 -0
  66. package/src/skills/catalog.ts +61 -0
  67. package/src/tui/app.ts +297 -0
  68. package/src/tui/components/ascii-art.ts +340 -0
  69. package/src/tui/components/autocomplete.ts +165 -0
  70. package/src/tui/components/capability.ts +29 -0
  71. package/src/tui/components/code-view.ts +146 -0
  72. package/src/tui/components/color.ts +172 -0
  73. package/src/tui/components/config-panel.ts +193 -0
  74. package/src/tui/components/evolution.ts +305 -0
  75. package/src/tui/components/footer.ts +95 -0
  76. package/src/tui/components/forge.ts +167 -0
  77. package/src/tui/components/index.ts +7 -0
  78. package/src/tui/components/layout.ts +105 -0
  79. package/src/tui/components/meter.ts +61 -0
  80. package/src/tui/components/model-picker.ts +82 -0
  81. package/src/tui/components/provider-picker.ts +42 -0
  82. package/src/tui/components/select-list.ts +199 -0
  83. package/src/tui/components/slash.ts +34 -0
  84. package/src/tui/components/spinner.ts +49 -0
  85. package/src/tui/components/status.ts +45 -0
  86. package/src/tui/components/stream.ts +36 -0
  87. package/src/tui/components/themes.ts +86 -0
  88. package/src/tui/components/tool-list.ts +67 -0
  89. package/src/tui/index.ts +2 -0
  90. package/src/tui/renderer.ts +70 -0
  91. package/src/tui/terminal.ts +78 -0
  92. package/src/util/retry.ts +108 -0
  93. package/tsconfig.json +18 -0
@@ -0,0 +1,11 @@
1
+ export * from "./types";
2
+ export * from "./model-manager";
3
+ export * from "./provider-status";
4
+ export * from "./model-discovery";
5
+ export * from "./model-picker";
6
+ export * from "./model-catalog";
7
+ export * from "./model-enrich";
8
+ export { anthropicAdapter } from "./providers/anthropic";
9
+ export { openaiAdapter } from "./providers/openai";
10
+ export { geminiAdapter } from "./providers/gemini";
11
+ export { ollamaAdapter } from "./providers/ollama";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Compatibility adapter over `model-catalog.ts`.
3
+ *
4
+ * The canonical catalog (`CatalogModel`: canonical/providerModel/contextTokens/
5
+ * thinking/images) is owned elsewhere. The model picker, setup flow, registry,
6
+ * router, and autocomplete were written against a simpler `{ id, provider,
7
+ * contextWindow, reasoning, recommended, note }` shape — this module adapts the
8
+ * canonical catalog into that shape (and adds `recommendedModel` / `validateModelId`
9
+ * / `suggestModels`) so those consumers stay decoupled from catalog churn.
10
+ */
11
+ import { MODEL_CATALOG, findCatalogModel, catalogByProvider, formatTokens, type CatalogModel } from "./model-catalog";
12
+ import type { ProviderName } from "./types";
13
+
14
+ export interface ModelCatalogEntry {
15
+ id: string;
16
+ provider: ProviderName;
17
+ contextWindow: number;
18
+ reasoning: boolean;
19
+ recommended?: boolean;
20
+ note?: string;
21
+ }
22
+
23
+ /** Canonical ids that should be surfaced as a provider's recommended default. */
24
+ const RECOMMENDED = new Set(["claude-3-5-sonnet", "gpt-4o", "gemini-2.0-flash", "qwen2.5"]);
25
+
26
+ export function normalizeModelId(id: string): string {
27
+ return (id ?? "").trim().toLowerCase();
28
+ }
29
+
30
+ function adapt(m: CatalogModel): ModelCatalogEntry {
31
+ const reasoning = m.thinking.length > 0;
32
+ return {
33
+ id: m.canonical,
34
+ provider: m.provider,
35
+ contextWindow: m.contextTokens,
36
+ reasoning,
37
+ recommended: RECOMMENDED.has(m.canonical),
38
+ note: `${formatTokens(m.contextTokens)} ctx${reasoning ? ", reasoning" : ""}`,
39
+ };
40
+ }
41
+
42
+ /** Exact (then normalized) catalog lookup; undefined when uncatalogued. */
43
+ export function findCatalogEntry(id: string): ModelCatalogEntry | undefined {
44
+ const direct = findCatalogModel(id);
45
+ if (direct) return adapt(direct);
46
+ const n = normalizeModelId(id);
47
+ const hit = MODEL_CATALOG.find(m => normalizeModelId(m.canonical) === n || normalizeModelId(m.providerModel) === n);
48
+ return hit ? adapt(hit) : undefined;
49
+ }
50
+
51
+ /** Entries for a provider, recommended first then by id. */
52
+ export function catalogForProvider(provider: ProviderName): ModelCatalogEntry[] {
53
+ return catalogByProvider(provider)
54
+ .map(adapt)
55
+ .sort((a, b) => {
56
+ if (!!b.recommended !== !!a.recommended) return b.recommended ? 1 : -1;
57
+ return a.id.localeCompare(b.id);
58
+ });
59
+ }
60
+
61
+ /** The recommended model id for a provider (first recommended, else first listed). */
62
+ export function recommendedModel(provider: ProviderName): string | undefined {
63
+ const list = catalogForProvider(provider);
64
+ return (list.find(e => e.recommended) ?? list[0])?.id;
65
+ }
66
+
67
+ export interface ModelValidation {
68
+ known: boolean;
69
+ entry?: ModelCatalogEntry;
70
+ providerMatch?: boolean;
71
+ }
72
+
73
+ /** Validate a model id against the catalog (and optionally an expected provider). */
74
+ export function validateModelId(id: string, expectedProvider?: ProviderName): ModelValidation {
75
+ const entry = findCatalogEntry(id);
76
+ const result: ModelValidation = { known: !!entry, entry };
77
+ if (expectedProvider && entry) result.providerMatch = entry.provider === expectedProvider;
78
+ return result;
79
+ }
80
+
81
+ /** Levenshtein edit distance (small strings; iterative DP). */
82
+ export function editDistance(a: string, b: string): number {
83
+ const m = a.length;
84
+ const n = b.length;
85
+ if (m === 0) return n;
86
+ if (n === 0) return m;
87
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
88
+ let curr = new Array<number>(n + 1);
89
+ for (let i = 1; i <= m; i++) {
90
+ curr[0] = i;
91
+ for (let j = 1; j <= n; j++) {
92
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
93
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
94
+ }
95
+ [prev, curr] = [curr, prev];
96
+ }
97
+ return prev[n]!;
98
+ }
99
+
100
+ /** "Did you mean" suggestions for an unrecognized model id (nearest canonical ids). */
101
+ export function suggestModels(input: string, limit = 3): string[] {
102
+ const n = normalizeModelId(input);
103
+ if (!n) return [];
104
+ const scored = MODEL_CATALOG.map(m => {
105
+ const id = normalizeModelId(m.canonical);
106
+ const contains = id.includes(n) || n.includes(id);
107
+ return { id: m.canonical, score: contains ? -1 : editDistance(n, id) };
108
+ });
109
+ return scored
110
+ .filter(s => s.score <= Math.max(3, Math.floor(n.length / 2)))
111
+ .sort((a, b) => a.score - b.score)
112
+ .slice(0, limit)
113
+ .map(s => s.id);
114
+ }
115
+
116
+ /** All canonical catalog ids (for autocomplete). */
117
+ export function catalogIds(): string[] {
118
+ return MODEL_CATALOG.map(m => m.canonical);
119
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Static model catalog — capability metadata for well-known public models, so
3
+ * the TUI can show context window, max output, supported thinking levels, and
4
+ * image support next to a model (gjc `--list-models` design parity, reimplemented
5
+ * in joc's own structure). This is factual capability data about public models,
6
+ * not a copy of any vendor's catalog source. Live discovery
7
+ * (`model-discovery.ts`) remains the source of truth for *availability*; this
8
+ * catalog annotates known ids with capabilities.
9
+ */
10
+ import type { ProviderName } from "./types";
11
+
12
+ export type ThinkLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
13
+
14
+ export const THINK_LEVELS: readonly ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
15
+
16
+ export interface CatalogModel {
17
+ /** joc-facing canonical id (what a user types). */
18
+ canonical: string;
19
+ provider: ProviderName;
20
+ /** Exact provider model id used on the wire. */
21
+ providerModel: string;
22
+ /** Approximate context window in tokens. */
23
+ contextTokens: number;
24
+ /** Approximate max output tokens. */
25
+ maxOutputTokens: number;
26
+ /** Supported thinking/reasoning levels ([] = none). */
27
+ thinking: ThinkLevel[];
28
+ /** Whether the model accepts image input. */
29
+ images: boolean;
30
+ }
31
+
32
+ const FULL: ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
33
+ const STD: ThinkLevel[] = ["minimal", "low", "medium", "high"];
34
+
35
+ /** A curated set of common public models with their documented capabilities. */
36
+ export const MODEL_CATALOG: readonly CatalogModel[] = [
37
+ // Anthropic
38
+ { canonical: "claude-3-5-haiku", provider: "anthropic", providerModel: "claude-3-5-haiku-latest", contextTokens: 200_000, maxOutputTokens: 8_192, thinking: [], images: true },
39
+ { canonical: "claude-3-5-sonnet", provider: "anthropic", providerModel: "claude-3-5-sonnet-20241022", contextTokens: 200_000, maxOutputTokens: 8_192, thinking: [], images: true },
40
+ { canonical: "claude-3-7-sonnet", provider: "anthropic", providerModel: "claude-3-7-sonnet-20250219", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
41
+ { canonical: "claude-sonnet-4", provider: "anthropic", providerModel: "claude-sonnet-4-20250514", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
42
+ { canonical: "claude-opus-4", provider: "anthropic", providerModel: "claude-opus-4-20250514", contextTokens: 200_000, maxOutputTokens: 32_000, thinking: FULL, images: true },
43
+ // OpenAI
44
+ { canonical: "gpt-4o", provider: "openai", providerModel: "gpt-4o", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
45
+ { canonical: "gpt-4o-mini", provider: "openai", providerModel: "gpt-4o-mini", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
46
+ { canonical: "gpt-4.1", provider: "openai", providerModel: "gpt-4.1", contextTokens: 1_000_000, maxOutputTokens: 32_768, thinking: [], images: true },
47
+ { canonical: "o3", provider: "openai", providerModel: "o3", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: true },
48
+ { canonical: "o3-mini", provider: "openai", providerModel: "o3-mini", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: false },
49
+ { canonical: "o4-mini", provider: "openai", providerModel: "o4-mini", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: true },
50
+ // Google
51
+ { canonical: "gemini-1.5-pro", provider: "gemini", providerModel: "gemini-1.5-pro", contextTokens: 1_000_000, maxOutputTokens: 8_192, thinking: [], images: true },
52
+ { canonical: "gemini-2.0-flash", provider: "gemini", providerModel: "gemini-2.0-flash", contextTokens: 1_000_000, maxOutputTokens: 8_192, thinking: [], images: true },
53
+ { canonical: "gemini-2.5-flash", provider: "gemini", providerModel: "gemini-2.5-flash", contextTokens: 1_000_000, maxOutputTokens: 65_536, thinking: STD, images: true },
54
+ { canonical: "gemini-2.5-pro", provider: "gemini", providerModel: "gemini-2.5-pro", contextTokens: 1_000_000, maxOutputTokens: 65_536, thinking: STD, images: true },
55
+ // Ollama (local)
56
+ { canonical: "qwen2.5", provider: "ollama", providerModel: "ollama/qwen2.5:0.5b", contextTokens: 32_768, maxOutputTokens: 8_192, thinking: [], images: false },
57
+ ];
58
+
59
+ /** Format a token count compactly (1000 → 1K, 1_000_000 → 1M). */
60
+ export function formatTokens(n: number): string {
61
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
62
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
63
+ return String(n);
64
+ }
65
+
66
+ /** Exact lookup by canonical id or provider model id. */
67
+ export function findCatalogModel(idOrModel: string): CatalogModel | undefined {
68
+ const q = idOrModel.trim();
69
+ return MODEL_CATALOG.find(m => m.canonical === q || m.providerModel === q || `${m.provider}/${m.providerModel}` === q);
70
+ }
71
+
72
+ /** Case-insensitive substring match over canonical + provider model id. */
73
+ export function fuzzyMatchCatalog(query: string): CatalogModel[] {
74
+ const q = query.trim().toLowerCase();
75
+ if (!q) return [];
76
+ return MODEL_CATALOG.filter(m => m.canonical.toLowerCase().includes(q) || m.providerModel.toLowerCase().includes(q) || m.provider.includes(q));
77
+ }
78
+
79
+ /** Catalog entries for a single provider. */
80
+ export function catalogByProvider(provider: ProviderName): CatalogModel[] {
81
+ return MODEL_CATALOG.filter(m => m.provider === provider);
82
+ }
83
+
84
+ /** Annotate a discovered/raw model id with catalog metadata, when known. */
85
+ export function catalogMetadata(modelId: string): CatalogModel | undefined {
86
+ const direct = findCatalogModel(modelId);
87
+ if (direct) return direct;
88
+ // Tolerate provider-prefixed or bare provider model ids.
89
+ const bare = modelId.replace(/^[a-z-]+\//, "");
90
+ return MODEL_CATALOG.find(m => m.providerModel === bare || m.providerModel.endsWith(`/${bare}`) || m.canonical === bare);
91
+ }
92
+
93
+ /** Whether a model supports a given thinking level (per the catalog). */
94
+ export function supportsThinking(modelId: string, level: ThinkLevel): boolean {
95
+ const meta = catalogMetadata(modelId);
96
+ return meta ? meta.thinking.includes(level) : false;
97
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Live model discovery — query a provider's `models` endpoint with the resolved
3
+ * credential (OAuth bearer or API key) and return the model ids the account can
4
+ * actually use. This powers the TUI `/models` / `/model` / `/provider` flows and
5
+ * `joc models`, so users pick from the real, logged-in catalog instead of a
6
+ * static alias guess.
7
+ *
8
+ * Network access is injectable (`fetchImpl`) and every call is timeout-bounded so
9
+ * the TUI never hangs; failures degrade to a tagged result, never a throw.
10
+ */
11
+ import { readGlobalConfig, type Config } from "../agent/state";
12
+ import { resolveCredential, type AuthProvider, type Credential } from "../auth";
13
+ import type { ProviderName } from "./types";
14
+ import { PROVIDER_NAMES } from "./provider-status";
15
+
16
+ export interface ProviderModelsResult {
17
+ provider: ProviderName;
18
+ /** Discovered model ids (provider-qualified where the router expects it). */
19
+ models: string[];
20
+ ok: boolean;
21
+ /** How the request authenticated (for display). */
22
+ source: "oauth" | "api_key" | "keyless" | "none";
23
+ /** Present on failure: a short, human-readable reason. */
24
+ error?: string;
25
+ }
26
+
27
+ export interface DiscoveryOptions {
28
+ baseUrl?: string;
29
+ /** Injectable fetch (tests). Defaults to global fetch. */
30
+ fetchImpl?: typeof fetch;
31
+ /** Per-request timeout; default 5000ms. */
32
+ timeoutMs?: number;
33
+ signal?: AbortSignal;
34
+ /** Cap the number of returned ids per provider; default 100. */
35
+ limit?: number;
36
+ }
37
+
38
+ const DEFAULT_TIMEOUT = 5000;
39
+ const DEFAULT_LIMIT = 100;
40
+
41
+ function anthropicHeaders(cred: Credential): Record<string, string> {
42
+ if (cred.kind === "oauth") {
43
+ return { authorization: `Bearer ${cred.token}`, "anthropic-version": "2023-06-01", "anthropic-beta": "oauth-2025-04-20" };
44
+ }
45
+ if (cred.kind === "api_key") {
46
+ return { "x-api-key": cred.token, "anthropic-version": "2023-06-01" };
47
+ }
48
+ return {};
49
+ }
50
+
51
+ /** Build the discovery request (url + headers) for a provider/credential. */
52
+ export function discoveryRequest(
53
+ provider: ProviderName,
54
+ cred: Credential | undefined,
55
+ baseUrl?: string,
56
+ ): { url: string; headers: Record<string, string> } {
57
+ switch (provider) {
58
+ case "anthropic":
59
+ return { url: "https://api.anthropic.com/v1/models", headers: anthropicHeaders(cred!) };
60
+ case "openai": {
61
+ const base = (baseUrl ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/$/, "");
62
+ const token = cred?.kind === "oauth" || cred?.kind === "api_key" ? cred.token : "";
63
+ return { url: `${base}/models`, headers: token ? { Authorization: `Bearer ${token}` } : {} };
64
+ }
65
+ case "gemini": {
66
+ const oauth = cred?.kind === "oauth" ? cred.token : undefined;
67
+ const apiKey = cred?.kind === "api_key" ? cred.token : undefined;
68
+ const url = oauth
69
+ ? "https://generativelanguage.googleapis.com/v1beta/models"
70
+ : `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`;
71
+ return { url, headers: oauth ? { authorization: `Bearer ${oauth}` } : {} };
72
+ }
73
+ case "ollama": {
74
+ const base = (baseUrl ?? "http://localhost:11434").replace(/\/$/, "");
75
+ return { url: `${base}/api/tags`, headers: {} };
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Parse a provider's models response body into normalized model ids. */
81
+ export function parseModelsBody(provider: ProviderName, body: unknown): string[] {
82
+ const data = body as { data?: { id?: string }[]; models?: { name?: string }[] };
83
+ if (provider === "ollama") {
84
+ return (data.models ?? []).map(m => `ollama/${m.name ?? ""}`).filter(s => s !== "ollama/");
85
+ }
86
+ if (provider === "gemini") {
87
+ return (data.models ?? []).map(m => (m.name ?? "").replace(/^models\//, "")).filter(Boolean);
88
+ }
89
+ // anthropic / openai: { data: [{ id }] }
90
+ return (data.data ?? []).map(m => m.id ?? "").filter(Boolean);
91
+ }
92
+
93
+ /** Discover the live model list for one provider. Never throws. */
94
+ export async function listProviderModels(
95
+ provider: ProviderName,
96
+ opts: DiscoveryOptions = {},
97
+ ): Promise<ProviderModelsResult> {
98
+ const fetchImpl = opts.fetchImpl ?? fetch;
99
+ const limit = opts.limit ?? DEFAULT_LIMIT;
100
+
101
+ let cred: Credential | undefined;
102
+ let source: ProviderModelsResult["source"] = "keyless";
103
+ if (provider !== "ollama") {
104
+ cred = await resolveCredential(provider as AuthProvider);
105
+ source = cred.kind === "oauth" ? "oauth" : cred.kind === "api_key" ? "api_key" : "none";
106
+ const isLocalOpenAi = provider === "openai" && !!(opts.baseUrl ?? process.env.OPENAI_BASE_URL);
107
+ if (source === "none" && !isLocalOpenAi) {
108
+ return { provider, models: [], ok: false, source, error: "not logged in" };
109
+ }
110
+ }
111
+
112
+ const { url, headers } = discoveryRequest(provider, cred, opts.baseUrl);
113
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT;
114
+ const signal = opts.signal ?? AbortSignal.timeout(timeout);
115
+ try {
116
+ const res = await fetchImpl(url, { headers, signal });
117
+ if (!res.ok) {
118
+ const reason = res.status === 401 || res.status === 403 ? "auth rejected" : `HTTP ${res.status}`;
119
+ return { provider, models: [], ok: false, source, error: reason };
120
+ }
121
+ const body = await res.json();
122
+ const models = parseModelsBody(provider, body).sort().slice(0, limit);
123
+ return { provider, models, ok: true, source };
124
+ } catch (err) {
125
+ const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
126
+ return { provider, models: [], ok: false, source, error: msg };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Discover live models across providers. By default only queries providers that
132
+ * are logged in / reachable (skips `none` cloud providers); ollama is always
133
+ * probed. Runs in parallel.
134
+ */
135
+ export async function discoverModels(
136
+ opts: DiscoveryOptions & { providers?: ProviderName[]; config?: Config } = {},
137
+ ): Promise<ProviderModelsResult[]> {
138
+ const cfg = opts.config ?? (await readGlobalConfig());
139
+ const providers = opts.providers ?? [...PROVIDER_NAMES];
140
+ return Promise.all(
141
+ providers.map(p =>
142
+ listProviderModels(p, {
143
+ ...opts,
144
+ baseUrl: p === "ollama" ? (cfg.ollamaBaseUrl ?? opts.baseUrl) : p === "openai" ? (cfg.openaiBaseUrl ?? opts.baseUrl) : opts.baseUrl,
145
+ }),
146
+ ),
147
+ );
148
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Live + catalog merge — annotate the OAuth/API-key-discovered model list
3
+ * (`model-discovery.ts`) with capability metadata from the static catalog
4
+ * (`model-catalog.ts`). This is the bridge that lets the TUI show context window,
5
+ * max output, thinking levels, and image support next to the models a user can
6
+ * *actually* use right now (gjc provider-table parity, applied to live results).
7
+ * Pure functions over discovery results + catalog; no I/O.
8
+ */
9
+ import type { ProviderModelsResult } from "./model-discovery";
10
+ import { catalogMetadata, type CatalogModel, type ThinkLevel } from "./model-catalog";
11
+ import type { ProviderName } from "./types";
12
+
13
+ export interface EnrichedModel {
14
+ /** Live model id as returned by discovery (provider-qualified where relevant). */
15
+ id: string;
16
+ provider: ProviderName;
17
+ /** Catalog capabilities when the id is known; undefined for unknown ids. */
18
+ meta?: CatalogModel;
19
+ }
20
+
21
+ /** Enrich a single (successful) discovery result; failed results yield []. */
22
+ export function enrichResult(result: ProviderModelsResult): EnrichedModel[] {
23
+ if (!result.ok) return [];
24
+ return result.models.map(id => ({ id, provider: result.provider, meta: catalogMetadata(id) }));
25
+ }
26
+
27
+ /** Enrich every successful provider result, preserving provider/order. */
28
+ export function enrichAll(results: ProviderModelsResult[]): EnrichedModel[] {
29
+ return results.flatMap(enrichResult);
30
+ }
31
+
32
+ /** Split into known (catalog-annotated) vs unknown counts. */
33
+ export function knownCount(models: EnrichedModel[]): { known: number; unknown: number } {
34
+ let known = 0;
35
+ for (const m of models) if (m.meta) known++;
36
+ return { known, unknown: models.length - known };
37
+ }
38
+
39
+ /**
40
+ * Sort by capability: catalog-known models first (largest context window first),
41
+ * then unknown ids alphabetically. Stable, non-mutating.
42
+ */
43
+ export function sortByCapability(models: EnrichedModel[]): EnrichedModel[] {
44
+ return [...models].sort((a, b) => {
45
+ if (a.meta && b.meta) {
46
+ if (b.meta.contextTokens !== a.meta.contextTokens) return b.meta.contextTokens - a.meta.contextTokens;
47
+ return a.id.localeCompare(b.id);
48
+ }
49
+ if (a.meta) return -1;
50
+ if (b.meta) return 1;
51
+ return a.id.localeCompare(b.id);
52
+ });
53
+ }
54
+
55
+ export interface CapabilityFilter {
56
+ /** Keep only models whose catalog supports this thinking level. */
57
+ thinking?: ThinkLevel;
58
+ /** Keep only models with image input (true) / without (false). */
59
+ images?: boolean;
60
+ /** Keep only models with at least this context window. */
61
+ minContext?: number;
62
+ }
63
+
64
+ /** Filter enriched models by capability. Unknown-meta models are excluded when any filter is set. */
65
+ export function filterCapable(models: EnrichedModel[], filter: CapabilityFilter): EnrichedModel[] {
66
+ const active = filter.thinking !== undefined || filter.images !== undefined || filter.minContext !== undefined;
67
+ if (!active) return models;
68
+ return models.filter(m => {
69
+ if (!m.meta) return false;
70
+ if (filter.thinking !== undefined && !m.meta.thinking.includes(filter.thinking)) return false;
71
+ if (filter.images !== undefined && m.meta.images !== filter.images) return false;
72
+ if (filter.minContext !== undefined && m.meta.contextTokens < filter.minContext) return false;
73
+ return true;
74
+ });
75
+ }
@@ -0,0 +1,178 @@
1
+ import { OAUTH_FLOW_REGISTRY } from "../auth/flows";
2
+ import { readGlobalConfig } from "../agent/state";
3
+ import { resolveCredential, type AuthProvider, type Credential } from "../auth";
4
+ import { anthropicAdapter } from "./providers/anthropic";
5
+ import { openaiAdapter } from "./providers/openai";
6
+ import { geminiAdapter } from "./providers/gemini";
7
+ import { ollamaAdapter } from "./providers/ollama";
8
+ import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
9
+ import { expandAlias, resolveModelId, effectiveAliasesFor } from "./model-registry";
10
+ import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
11
+ import { withRetry, defaultRetryable, type RetryOptions } from "../util/retry";
12
+ import type { Config } from "../agent/state";
13
+
14
+ const ADAPTERS: Record<ProviderName, ProviderAdapter> = {
15
+ anthropic: anthropicAdapter,
16
+ openai: openaiAdapter,
17
+ gemini: geminiAdapter,
18
+ ollama: ollamaAdapter,
19
+ };
20
+
21
+ export function resolveProvider(model: string): ProviderName {
22
+ // Catalog is authoritative for known ids (correct even when heuristics would
23
+ // misroute a future/edge id); heuristics handle everything uncatalogued.
24
+ const entry = findCatalogEntry(model);
25
+ if (entry) return entry.provider;
26
+ const m = (model ?? "").toLowerCase();
27
+ if (m.startsWith("ollama/")) return "ollama";
28
+ // OpenAI: explicit prefix, any GPT, or a reasoning model (o1/o3/o4-mini, o1-preview…).
29
+ if (m.startsWith("openai/") || m.includes("gpt") || /(^|\/)o\d/.test(m)) return "openai";
30
+ if (m.startsWith("google/") || m.includes("gemini")) return "gemini";
31
+ return "anthropic";
32
+ }
33
+
34
+ /** Map the configured thinking level to a default max-token budget. */
35
+ export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high" | "xhigh"): number {
36
+ if (level === "minimal") return 1000;
37
+ if (level === "low") return 2000;
38
+ if (level === "high") return 8000;
39
+ if (level === "xhigh") return 16000;
40
+ return 4000;
41
+ }
42
+
43
+ /** Describe a model id: alias expansion + the provider it routes to. For `/model` + diagnostics. */
44
+ export async function describeModel(input: string): Promise<{ input: string; resolved: string; provider: ProviderName }> {
45
+ const resolved = await resolveModelId(input);
46
+ return { input, resolved, provider: resolveProvider(resolved) };
47
+ }
48
+
49
+ export type ModelRole = "smol" | "slow" | "plan";
50
+
51
+ /** Resolve a model role tier (smol/slow/plan) → configured tier model, else defaultModel. */
52
+ export function resolveRoleModel(role: ModelRole, config: { defaultModel: string; roles?: { smol?: string; slow?: string; plan?: string } }): string {
53
+ return config.roles?.[role] || config.defaultModel;
54
+ }
55
+
56
+ export interface ModelDescription {
57
+ input: string;
58
+ resolved: string;
59
+ provider: ProviderName;
60
+ /** Catalog metadata when the resolved id is known (context window, reasoning…). */
61
+ entry?: ModelCatalogEntry;
62
+ /** Alias names that expand to the resolved id. */
63
+ aliases: string[];
64
+ }
65
+
66
+ /**
67
+ * Rich model description for the `/model` panel + diagnostics: alias expansion,
68
+ * routed provider, catalog metadata (context window, reasoning, recommended),
69
+ * and the reverse-alias list. Falls back gracefully for uncatalogued ids.
70
+ */
71
+ export async function describeModelDetailed(input: string): Promise<ModelDescription> {
72
+ const { resolved, provider } = await describeModel(input);
73
+ return {
74
+ input,
75
+ resolved,
76
+ provider,
77
+ entry: findCatalogEntry(resolved),
78
+ aliases: await effectiveAliasesFor(resolved),
79
+ };
80
+ }
81
+
82
+ export interface ModelManager {
83
+ call(messages: Message[], options?: Partial<CallOptions>): Promise<string>;
84
+ stream(messages: Message[], options?: Partial<CallOptions>): AsyncIterable<string>;
85
+ resolveProvider: typeof resolveProvider;
86
+ }
87
+
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" };
89
+
90
+ /**
91
+ * Build retry options from a config `retry` budget (gjc parity). `requestMaxRetries`
92
+ * 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.
95
+ */
96
+ export function resolveRetryOptions(retry: Config["retry"]): RetryOptions {
97
+ const opts: RetryOptions = { isRetryable: defaultRetryable };
98
+ if (typeof retry?.requestMaxRetries === "number") {
99
+ opts.retries = retry.requestMaxRetries + 1;
100
+ }
101
+ if (typeof retry?.maxDelayMs === "number") {
102
+ opts.maxDelayMs = retry.maxDelayMs;
103
+ }
104
+ return opts;
105
+ }
106
+
107
+ interface Resolved {
108
+ adapter: ProviderAdapter;
109
+ callOptions: CallOptions;
110
+ credential: Credential;
111
+ retry: RetryOptions;
112
+ }
113
+
114
+ async function resolveCall(options: Partial<CallOptions>): Promise<Resolved> {
115
+ const config = await readGlobalConfig();
116
+ const aliases = { ...((config as { modelAliases?: Record<string, string> }).modelAliases ?? {}) };
117
+ const model = expandAlias(options.model ?? config.defaultModel, { ...ALIAS_DEFAULTS, ...aliases });
118
+ const provider = resolveProvider(model);
119
+ const adapter = ADAPTERS[provider];
120
+
121
+ const baseUrl =
122
+ options.baseUrl ??
123
+ (provider === "openai" ? config.openaiBaseUrl : undefined) ??
124
+ (provider === "ollama" ? config.ollamaBaseUrl : undefined);
125
+
126
+ const callOptions: CallOptions = {
127
+ model,
128
+ systemPrompt: options.systemPrompt,
129
+ temperature: options.temperature ?? 0.2,
130
+ maxTokens: options.maxTokens ?? thinkingMaxTokens(config.thinkingLevel),
131
+ jsonMode: options.jsonMode,
132
+ baseUrl,
133
+ onUsage: options.onUsage,
134
+ signal: options.signal,
135
+ };
136
+
137
+ if (provider === "ollama") {
138
+ return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry: resolveRetryOptions(config.retry) };
139
+ }
140
+
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}.`);
149
+ }
150
+ }
151
+
152
+ const isLocalOpenAi = provider === "openai" && !!baseUrl;
153
+ if (effective.kind === "none" && !isLocalOpenAi) {
154
+ 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.`
156
+ );
157
+ }
158
+ return { adapter, callOptions, credential: effective, retry: resolveRetryOptions(config.retry) };
159
+ }
160
+
161
+ export function createModelManager(): ModelManager {
162
+ return {
163
+ resolveProvider,
164
+ async call(messages, options = {}) {
165
+ const { adapter, callOptions, credential, retry } = await resolveCall(options);
166
+ return withRetry(() => adapter.call(messages, callOptions, credential), retry);
167
+ },
168
+ async *stream(messages, options = {}) {
169
+ const { adapter, callOptions, credential, retry } = await resolveCall(options);
170
+ if (adapter.stream) {
171
+ yield* adapter.stream(messages, callOptions, credential);
172
+ } else {
173
+ // Fallback: providers without streaming yield the full response as one chunk.
174
+ yield await withRetry(() => adapter.call(messages, callOptions, credential), retry);
175
+ }
176
+ },
177
+ };
178
+ }