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,73 @@
1
+ /**
2
+ * Model picker — turn a live discovery result set into a flat, 1-based pick list
3
+ * so the TUI can select a model by number (`/model #3`) or by a fuzzy substring
4
+ * (`/model gpt-4`). Pure functions over `ProviderModelsResult[]`, so they are
5
+ * fully unit-testable and shared by `/model`, `/models`, and `/provider`.
6
+ */
7
+ import type { ProviderModelsResult } from "./model-discovery";
8
+ import type { ProviderName } from "./types";
9
+
10
+ export interface PickEntry {
11
+ index: number; // 1-based position in the flattened list
12
+ provider: ProviderName;
13
+ model: string;
14
+ }
15
+
16
+ /** Flatten successful discovery results into an ordered, 1-based pick list. */
17
+ export function flattenModels(results: ProviderModelsResult[]): PickEntry[] {
18
+ const out: PickEntry[] = [];
19
+ for (const r of results) {
20
+ if (!r.ok) continue;
21
+ for (const model of r.models) {
22
+ out.push({ index: out.length + 1, provider: r.provider, model });
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** Parse a `#N` selection token into a 1-based index, else null. */
29
+ export function parsePickToken(token: string): number | null {
30
+ const m = token.trim().match(/^#(\d+)$/);
31
+ if (!m) return null;
32
+ const n = parseInt(m[1], 10);
33
+ return n >= 1 ? n : null;
34
+ }
35
+
36
+ /** Resolve a 1-based index into a pick entry, or undefined when out of range. */
37
+ export function pickByIndex(flat: PickEntry[], n: number): PickEntry | undefined {
38
+ return flat[n - 1];
39
+ }
40
+
41
+ /** Case-insensitive substring match over the flat model ids. */
42
+ export function matchModels(flat: PickEntry[], query: string): PickEntry[] {
43
+ const q = query.trim().toLowerCase();
44
+ if (!q) return [];
45
+ return flat.filter(e => e.model.toLowerCase().includes(q));
46
+ }
47
+
48
+ export type Selection =
49
+ | { kind: "index"; entry: PickEntry }
50
+ | { kind: "match"; entry: PickEntry }
51
+ | { kind: "ambiguous"; matches: PickEntry[] }
52
+ | { kind: "out-of-range"; max: number }
53
+ | { kind: "none" };
54
+
55
+ /**
56
+ * Resolve a selection token against the flat list:
57
+ * - `#N` → exact index (or out-of-range)
58
+ * - substring → unique match, ambiguous (multiple), or none
59
+ */
60
+ export function resolveSelection(flat: PickEntry[], token: string): Selection {
61
+ const idx = parsePickToken(token);
62
+ if (idx !== null) {
63
+ const entry = pickByIndex(flat, idx);
64
+ return entry ? { kind: "index", entry } : { kind: "out-of-range", max: flat.length };
65
+ }
66
+ // Exact id match wins over substring.
67
+ const exact = flat.find(e => e.model === token);
68
+ if (exact) return { kind: "match", entry: exact };
69
+ const matches = matchModels(flat, token);
70
+ if (matches.length === 1) return { kind: "match", entry: matches[0] };
71
+ if (matches.length > 1) return { kind: "ambiguous", matches };
72
+ return { kind: "none" };
73
+ }
@@ -0,0 +1,83 @@
1
+ import { readGlobalConfig } from "../agent/state";
2
+ import { findCatalogEntry } from "./model-catalog-compat";
3
+
4
+ export interface ModelAliases {
5
+ [alias: string]: string;
6
+ }
7
+
8
+ // Built-in aliases (used when config has none for that key).
9
+ export const BUILTIN_ALIASES: ModelAliases = {
10
+ fast: "ollama/qwen2.5:0.5b",
11
+ local: "ollama/qwen2.5:0.5b",
12
+ sonnet: "claude-3-5-sonnet",
13
+ gpt: "gpt-4o",
14
+ flash: "gemini-2.5-flash",
15
+ };
16
+
17
+ // Expand an alias to a concrete model id. Unknown input passes through unchanged.
18
+ export function expandAlias(input: string, aliases: ModelAliases = BUILTIN_ALIASES): string {
19
+ if (Object.prototype.hasOwnProperty.call(aliases, input)) {
20
+ return aliases[input];
21
+ }
22
+ return input;
23
+ }
24
+
25
+ // Async: merge BUILTIN_ALIASES with config.modelAliases (config wins) and expand.
26
+ export async function resolveModelId(input: string): Promise<string> {
27
+ const config = await readGlobalConfig();
28
+ const modelAliases = (config as any).modelAliases ?? {};
29
+ const merged: ModelAliases = { ...BUILTIN_ALIASES, ...modelAliases };
30
+ return expandAlias(input, merged);
31
+ }
32
+
33
+ // List effective aliases (builtin + config).
34
+ export async function listAliases(): Promise<ModelAliases> {
35
+ const config = await readGlobalConfig();
36
+ const modelAliases = (config as any).modelAliases ?? {};
37
+ return { ...BUILTIN_ALIASES, ...modelAliases };
38
+ }
39
+
40
+ /** Alias names (sorted) whose target resolves to `id`. Reverse of `expandAlias`. */
41
+ export function aliasesFor(id: string, aliases: ModelAliases = BUILTIN_ALIASES): string[] {
42
+ return Object.entries(aliases)
43
+ .filter(([, target]) => target === id)
44
+ .map(([alias]) => alias)
45
+ .sort();
46
+ }
47
+
48
+ /** True when `input` is a defined alias (not a concrete model id). */
49
+ export function isAlias(input: string, aliases: ModelAliases = BUILTIN_ALIASES): boolean {
50
+ return Object.prototype.hasOwnProperty.call(aliases, input);
51
+ }
52
+
53
+ export interface AliasDescription {
54
+ alias: string;
55
+ target: string;
56
+ isAlias: boolean;
57
+ /** True when the target is a known catalog model id. */
58
+ knownTarget: boolean;
59
+ }
60
+
61
+ /** Describe an alias: its target + whether the target is a known catalog model. */
62
+ export function describeAlias(input: string, aliases: ModelAliases = BUILTIN_ALIASES): AliasDescription {
63
+ const defined = isAlias(input, aliases);
64
+ const target = defined ? aliases[input]! : input;
65
+ return { alias: input, target, isAlias: defined, knownTarget: !!findCatalogEntry(target) };
66
+ }
67
+
68
+ /**
69
+ * Validate an alias table: flag aliases whose target is not a known catalog
70
+ * model (advisory — uncatalogued targets still work, but a typo usually shows up
71
+ * here). Returns only the suspicious entries.
72
+ */
73
+ export function validateAliases(aliases: ModelAliases): { alias: string; target: string }[] {
74
+ return Object.entries(aliases)
75
+ .filter(([, target]) => !findCatalogEntry(target))
76
+ .map(([alias, target]) => ({ alias, target }))
77
+ .sort((a, b) => a.alias.localeCompare(b.alias));
78
+ }
79
+
80
+ /** Async: reverse-alias lookup against the effective (builtin + config) table. */
81
+ export async function effectiveAliasesFor(id: string): Promise<string[]> {
82
+ return aliasesFor(id, await listAliases());
83
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Provider credential/status inventory — the shared source of truth behind
3
+ * `joc models`, the TUI `/provider` command, and `joc doctor`. Reports, for each
4
+ * provider, how it will authenticate (API key / OAuth / keyless / none), its
5
+ * effective base URL, and whether it is ready to serve a request.
6
+ */
7
+ import { readGlobalConfig, type Config } from "../agent/state";
8
+ import { resolveCredential, type AuthProvider } from "../auth";
9
+ import type { ProviderName } from "./types";
10
+
11
+ export const PROVIDER_NAMES: readonly ProviderName[] = ["anthropic", "openai", "gemini", "ollama"];
12
+
13
+ /** Cloud providers that authenticate via API key / OAuth. Ollama is keyless. */
14
+ export const CLOUD_PROVIDERS: readonly AuthProvider[] = ["anthropic", "openai", "gemini"];
15
+
16
+ export type CredentialKind = "api_key" | "oauth" | "keyless" | "none";
17
+
18
+ export interface ProviderStatus {
19
+ name: ProviderName;
20
+ kind: CredentialKind;
21
+ /** Display label, e.g. "API key", "OAuth", "keyless (local)", "none (run 'joc setup')". */
22
+ label: string;
23
+ /** Effective base URL when relevant (ollama / openai-compatible). */
24
+ baseUrl?: string;
25
+ /** Environment variable that would supply an API key, when applicable. */
26
+ envVar?: string;
27
+ /** True when the provider can serve a request right now. */
28
+ ready: boolean;
29
+ }
30
+
31
+ /** The uppercase `<PROVIDER>_API_KEY` env var name for a cloud provider. */
32
+ export function providerEnvVar(name: ProviderName): string | undefined {
33
+ if (name === "ollama") return undefined;
34
+ return `${name.toUpperCase()}_API_KEY`;
35
+ }
36
+
37
+ /** Human label for a credential kind. */
38
+ export function credentialLabel(kind: CredentialKind): string {
39
+ switch (kind) {
40
+ case "api_key":
41
+ return "API key";
42
+ case "oauth":
43
+ return "OAuth";
44
+ case "keyless":
45
+ return "keyless (local)";
46
+ case "none":
47
+ return "none (run 'joc setup' or 'joc auth login')";
48
+ }
49
+ }
50
+
51
+ /** Resolve the status of a single provider. */
52
+ export async function describeProvider(name: ProviderName, config?: Config): Promise<ProviderStatus> {
53
+ const cfg = config ?? (await readGlobalConfig());
54
+ if (name === "ollama") {
55
+ const baseUrl = cfg.ollamaBaseUrl ?? "http://localhost:11434";
56
+ return { name, kind: "keyless", label: credentialLabel("keyless"), baseUrl, ready: true };
57
+ }
58
+ const cred = await resolveCredential(name as AuthProvider);
59
+ const kind: CredentialKind = cred.kind === "api_key" ? "api_key" : cred.kind === "oauth" ? "oauth" : "none";
60
+ const baseUrl = name === "openai" ? cfg.openaiBaseUrl : undefined;
61
+ // An OpenAI-compatible local server (LM Studio, vLLM) needs no key.
62
+ const ready = kind !== "none" || (name === "openai" && !!baseUrl);
63
+ return {
64
+ name,
65
+ kind,
66
+ label: ready && kind === "none" ? "keyless (local base URL)" : credentialLabel(kind),
67
+ baseUrl,
68
+ envVar: providerEnvVar(name),
69
+ ready,
70
+ };
71
+ }
72
+
73
+ /** Resolve the status of every provider (single config read). */
74
+ export async function describeAllProviders(config?: Config): Promise<ProviderStatus[]> {
75
+ const cfg = config ?? (await readGlobalConfig());
76
+ return Promise.all(PROVIDER_NAMES.map(name => describeProvider(name, cfg)));
77
+ }
@@ -0,0 +1,87 @@
1
+ import type { Credential } from "../../auth";
2
+ import type { CallOptions, Message, ProviderAdapter } from "../types";
3
+ import { readSse } from "../sse";
4
+ import { providerHttpError } from "./errors";
5
+
6
+ const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
7
+
8
+ function anthropicPayload(messages: Message[], options: CallOptions, stream: boolean): string {
9
+ const resolvedModel = options.model.startsWith("anthropic/") ? options.model.slice(10) : options.model;
10
+ const model = resolvedModel.includes("sonnet") ? "claude-3-5-sonnet-20241022" : resolvedModel;
11
+ const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
12
+ const anthropicMessages = messages.filter(m => m.role !== "system").map(m => ({ role: m.role, content: m.content }));
13
+ const payload: Record<string, unknown> = {
14
+ model,
15
+ messages: anthropicMessages,
16
+ max_tokens: options.maxTokens ?? 4000,
17
+ temperature: options.temperature ?? 0.2,
18
+ };
19
+ if (systemPrompt) payload.system = systemPrompt;
20
+ if (stream) payload.stream = true;
21
+ return JSON.stringify(payload);
22
+ }
23
+
24
+ export const anthropicAdapter: ProviderAdapter = {
25
+ name: "anthropic",
26
+ async call(messages, options, credential) {
27
+ const response = await fetch(ANTHROPIC_URL, {
28
+ method: "POST",
29
+ headers: headersFor(credential),
30
+ body: anthropicPayload(messages, options, false),
31
+ signal: options.signal,
32
+ });
33
+ if (!response.ok) throw await providerHttpError("Anthropic", response);
34
+ const result = (await response.json()) as { content: { type: string; text: string }[]; usage?: { input_tokens?: number; output_tokens?: number } };
35
+ if (result.usage) options.onUsage?.({ inputTokens: result.usage.input_tokens, outputTokens: result.usage.output_tokens });
36
+ return result.content.find(c => c.type === "text")?.text ?? "";
37
+ },
38
+ async *stream(messages, options, credential) {
39
+ const response = await fetch(ANTHROPIC_URL, {
40
+ method: "POST",
41
+ headers: headersFor(credential),
42
+ body: anthropicPayload(messages, options, true),
43
+ signal: options.signal,
44
+ });
45
+ if (!response.ok) throw await providerHttpError("Anthropic", response, "(stream)");
46
+ if (!response.body) return;
47
+ for await (const data of readSse(response.body)) {
48
+ let evt: {
49
+ type?: string;
50
+ delta?: { type?: string; text?: string };
51
+ message?: { usage?: { input_tokens?: number; output_tokens?: number } };
52
+ usage?: { output_tokens?: number };
53
+ };
54
+ try {
55
+ evt = JSON.parse(data);
56
+ } catch {
57
+ continue;
58
+ }
59
+ if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta" && evt.delta.text) {
60
+ yield evt.delta.text;
61
+ } else if (evt.type === "message_start" && evt.message?.usage) {
62
+ options.onUsage?.({ inputTokens: evt.message.usage.input_tokens, outputTokens: evt.message.usage.output_tokens });
63
+ } else if (evt.type === "message_delta" && evt.usage) {
64
+ options.onUsage?.({ outputTokens: evt.usage.output_tokens });
65
+ }
66
+ }
67
+ },
68
+ };
69
+
70
+ function headersFor(credential: Credential): Record<string, string> {
71
+ if (credential.kind === "oauth") {
72
+ return {
73
+ "content-type": "application/json",
74
+ authorization: `Bearer ${credential.token}`,
75
+ "anthropic-version": "2023-06-01",
76
+ "anthropic-beta": "oauth-2025-04-20",
77
+ };
78
+ }
79
+ if (credential.kind === "api_key") {
80
+ return {
81
+ "content-type": "application/json",
82
+ "x-api-key": credential.token,
83
+ "anthropic-version": "2023-06-01",
84
+ };
85
+ }
86
+ throw new Error("anthropic adapter requires a credential");
87
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Structured provider error so the retry layer can act on the HTTP status.
3
+ *
4
+ * Previously every adapter threw a bare `Error("... HTTP 429 ...")`. The retry
5
+ * predicate (`defaultRetryable`) inspects a numeric `.status`, so those bare
6
+ * errors were never retried — a 429 (rate limit) or 503/529 (overloaded) bubbled
7
+ * straight up instead of backing off. Carrying `.status` (and any `Retry-After`)
8
+ * fixes that and lets `withRetry` honor server-directed backoff.
9
+ */
10
+ export class ProviderHttpError extends Error {
11
+ readonly status: number;
12
+ readonly provider: string;
13
+ /** Server-directed backoff from a `Retry-After` header, in ms (if present). */
14
+ readonly retryAfterMs?: number;
15
+ constructor(provider: string, status: number, detail: string, context?: string, retryAfterMs?: number) {
16
+ super(`${provider} request failed (HTTP ${status})${context ? ` ${context}` : ""}: ${detail}`);
17
+ this.name = "ProviderHttpError";
18
+ this.status = status;
19
+ this.provider = provider;
20
+ this.retryAfterMs = retryAfterMs;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Parse a `Retry-After` header into ms. Supports the delta-seconds form
26
+ * (`"5"`) and the HTTP-date form (`"Wed, 21 Oct 2025 07:28:00 GMT"`).
27
+ * Returns undefined for missing/garbage values.
28
+ */
29
+ export function parseRetryAfter(value: string | null | undefined): number | undefined {
30
+ if (!value) return undefined;
31
+ const trimmed = value.trim();
32
+ const secs = Number(trimmed);
33
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
34
+ const when = Date.parse(trimmed);
35
+ if (!Number.isNaN(when)) return Math.max(0, when - Date.now());
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Build a {@link ProviderHttpError} from a non-ok `Response`, capturing the body
41
+ * and any `Retry-After`. Use at every adapter's `!response.ok` site so the retry
42
+ * layer sees a uniform, status-carrying, backoff-aware error.
43
+ */
44
+ export async function providerHttpError(provider: string, response: Response, context?: string): Promise<ProviderHttpError> {
45
+ const detail = await response.text().catch(() => "");
46
+ return new ProviderHttpError(provider, response.status, detail, context, parseRetryAfter(response.headers.get("retry-after")));
47
+ }
@@ -0,0 +1,77 @@
1
+ import type { Credential } from "../../auth";
2
+ import type { CallOptions, Message, ProviderAdapter } from "../types";
3
+ import { readSse } from "../sse";
4
+ import { providerHttpError } from "./errors";
5
+
6
+ function geminiRequest(messages: Message[], options: CallOptions, credential: Credential, action: "generateContent" | "streamGenerateContent"): { url: string; headers: Record<string, string>; body: string } {
7
+ const resolvedModel = options.model.startsWith("google/") ? options.model.slice(7) : options.model;
8
+ let geminiModel = resolvedModel;
9
+ if (!geminiModel || geminiModel === "claude-3-5-sonnet") geminiModel = "gemini-2.0-flash";
10
+
11
+ const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
12
+ const contents = messages
13
+ .filter(m => m.role !== "system")
14
+ .map(m => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] }));
15
+
16
+ const generationConfig: Record<string, unknown> = {
17
+ temperature: options.temperature ?? 0.2,
18
+ maxOutputTokens: options.maxTokens ?? 4000,
19
+ };
20
+ if (options.jsonMode) generationConfig.responseMimeType = "application/json";
21
+
22
+ const payload: Record<string, unknown> = { contents, generationConfig };
23
+ if (systemPrompt) payload.systemInstruction = { parts: [{ text: systemPrompt }] };
24
+
25
+ const oauth = credential.kind === "oauth" ? credential.token : undefined;
26
+ const apiKey = credential.kind === "api_key" ? credential.token : undefined;
27
+ let url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:${action}`;
28
+ const query = action === "streamGenerateContent" ? "alt=sse" : "";
29
+ if (!oauth) url += `?${query ? query + "&" : ""}key=${apiKey ?? ""}`;
30
+ else if (query) url += `?${query}`;
31
+ const headers: Record<string, string> = oauth
32
+ ? { "content-type": "application/json", authorization: `Bearer ${oauth}` }
33
+ : { "content-type": "application/json" };
34
+ return { url, headers, body: JSON.stringify(payload) };
35
+ }
36
+
37
+ interface GeminiChunk {
38
+ candidates?: { content?: { parts?: { text?: string }[] } }[];
39
+ usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
40
+ }
41
+
42
+ function textOf(chunk: GeminiChunk): string {
43
+ return chunk.candidates?.[0]?.content?.parts?.map(p => p.text ?? "").join("") ?? "";
44
+ }
45
+
46
+ export const geminiAdapter: ProviderAdapter = {
47
+ name: "gemini",
48
+ async call(messages, options, credential) {
49
+ const { url, headers, body } = geminiRequest(messages, options, credential, "generateContent");
50
+ const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
51
+ if (!response.ok) throw await providerHttpError("Gemini", response);
52
+ const result = (await response.json()) as GeminiChunk;
53
+ if (result.usageMetadata) {
54
+ options.onUsage?.({ inputTokens: result.usageMetadata.promptTokenCount, outputTokens: result.usageMetadata.candidatesTokenCount });
55
+ }
56
+ return textOf(result);
57
+ },
58
+ async *stream(messages, options, credential) {
59
+ const { url, headers, body } = geminiRequest(messages, options, credential, "streamGenerateContent");
60
+ const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
61
+ if (!response.ok) throw await providerHttpError("Gemini", response, "(stream)");
62
+ if (!response.body) return;
63
+ for await (const data of readSse(response.body)) {
64
+ let chunk: GeminiChunk;
65
+ try {
66
+ chunk = JSON.parse(data);
67
+ } catch {
68
+ continue;
69
+ }
70
+ const delta = textOf(chunk);
71
+ if (delta) yield delta;
72
+ if (chunk.usageMetadata) {
73
+ options.onUsage?.({ inputTokens: chunk.usageMetadata.promptTokenCount, outputTokens: chunk.usageMetadata.candidatesTokenCount });
74
+ }
75
+ }
76
+ },
77
+ };
@@ -0,0 +1,54 @@
1
+ import type { CallOptions, Message, ProviderAdapter } from "../types";
2
+ import { readLines } from "../sse";
3
+ import { providerHttpError } from "./errors";
4
+
5
+ function ollamaRequest(messages: Message[], options: CallOptions, stream: boolean): { url: string; body: string } {
6
+ const model = options.model.startsWith("ollama/") ? options.model.slice(7) : options.model;
7
+ const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
8
+ const chatMessages: { role: string; content: string }[] = [];
9
+ if (systemPrompt) chatMessages.push({ role: "system", content: systemPrompt });
10
+ for (const msg of messages) {
11
+ if (msg.role !== "system") chatMessages.push({ role: msg.role, content: msg.content });
12
+ }
13
+ const payload: Record<string, unknown> = {
14
+ model,
15
+ messages: chatMessages,
16
+ stream,
17
+ options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 4000 },
18
+ };
19
+ if (options.jsonMode) payload.format = "json";
20
+ const base = (options.baseUrl ?? process.env.OLLAMA_HOST ?? "http://localhost:11434").replace(/\/$/, "");
21
+ return { url: `${base}/api/chat`, body: JSON.stringify(payload) };
22
+ }
23
+
24
+ export const ollamaAdapter: ProviderAdapter = {
25
+ name: "ollama",
26
+ async call(messages, options) {
27
+ const { url, body } = ollamaRequest(messages, options, false);
28
+ const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, signal: options.signal });
29
+ if (!response.ok) throw await providerHttpError("Ollama", response, `at ${url}`);
30
+ const result = (await response.json()) as { message?: { content?: string }; prompt_eval_count?: number; eval_count?: number; total_duration?: number };
31
+ options.onUsage?.({ inputTokens: result.prompt_eval_count, outputTokens: result.eval_count, durationMs: result.total_duration ? Math.round(result.total_duration / 1e6) : undefined });
32
+ return result.message?.content ?? "";
33
+ },
34
+ async *stream(messages, options) {
35
+ const { url, body } = ollamaRequest(messages, options, true);
36
+ const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, signal: options.signal });
37
+ if (!response.ok) throw await providerHttpError("Ollama", response, `(stream) at ${url}`);
38
+ if (!response.body) return;
39
+ for await (const line of readLines(response.body)) {
40
+ let chunk: { message?: { content?: string }; done?: boolean; prompt_eval_count?: number; eval_count?: number; total_duration?: number };
41
+ try {
42
+ chunk = JSON.parse(line);
43
+ } catch {
44
+ continue;
45
+ }
46
+ const delta = chunk.message?.content;
47
+ if (delta) yield delta;
48
+ if (chunk.done) {
49
+ options.onUsage?.({ inputTokens: chunk.prompt_eval_count, outputTokens: chunk.eval_count, durationMs: chunk.total_duration ? Math.round(chunk.total_duration / 1e6) : undefined });
50
+ break;
51
+ }
52
+ }
53
+ },
54
+ };
@@ -0,0 +1,67 @@
1
+ import type { Credential } from "../../auth";
2
+ import type { CallOptions, Message, ProviderAdapter } from "../types";
3
+ import { readSse } from "../sse";
4
+ import { providerHttpError } from "./errors";
5
+
6
+ function openaiRequest(messages: Message[], options: CallOptions, credential: Credential, stream: boolean): { url: string; headers: Record<string, string>; body: string } {
7
+ const resolvedModel = options.model.startsWith("openai/") ? options.model.slice(7) : options.model;
8
+ const model = resolvedModel.includes("gpt-4o") ? "gpt-4o" : resolvedModel;
9
+ const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
10
+ const openaiMessages: { role: string; content: string }[] = [];
11
+ if (systemPrompt) openaiMessages.push({ role: "system", content: systemPrompt });
12
+ for (const msg of messages) {
13
+ if (msg.role !== "system") openaiMessages.push({ role: msg.role, content: msg.content });
14
+ }
15
+ const payload: Record<string, unknown> = {
16
+ model,
17
+ messages: openaiMessages,
18
+ temperature: options.temperature ?? 0.2,
19
+ max_tokens: options.maxTokens ?? 4000,
20
+ };
21
+ if (stream) {
22
+ payload.stream = true;
23
+ payload.stream_options = { include_usage: true };
24
+ }
25
+ if (options.jsonMode) payload.response_format = { type: "json_object" };
26
+ const base = (options.baseUrl ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/$/, "");
27
+ return {
28
+ url: `${base}/chat/completions`,
29
+ headers: { "content-type": "application/json", Authorization: `Bearer ${bearerFor(credential)}` },
30
+ body: JSON.stringify(payload),
31
+ };
32
+ }
33
+
34
+ export const openaiAdapter: ProviderAdapter = {
35
+ name: "openai",
36
+ async call(messages, options, credential) {
37
+ const { url, headers, body } = openaiRequest(messages, options, credential, false);
38
+ const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
39
+ if (!response.ok) throw await providerHttpError("OpenAI", response);
40
+ const result = (await response.json()) as { choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
41
+ if (result.usage) options.onUsage?.({ inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens });
42
+ return result.choices[0]?.message?.content ?? "";
43
+ },
44
+ async *stream(messages, options, credential) {
45
+ const { url, headers, body } = openaiRequest(messages, options, credential, true);
46
+ const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
47
+ if (!response.ok) throw await providerHttpError("OpenAI", response, "(stream)");
48
+ if (!response.body) return;
49
+ for await (const data of readSse(response.body)) {
50
+ let chunk: { choices?: { delta?: { content?: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
51
+ try {
52
+ chunk = JSON.parse(data);
53
+ } catch {
54
+ continue;
55
+ }
56
+ const delta = chunk.choices?.[0]?.delta?.content;
57
+ if (delta) yield delta;
58
+ if (chunk.usage) options.onUsage?.({ inputTokens: chunk.usage.prompt_tokens, outputTokens: chunk.usage.completion_tokens });
59
+ }
60
+ },
61
+ };
62
+
63
+ function bearerFor(credential: Credential): string {
64
+ if (credential.kind === "oauth") return credential.token;
65
+ if (credential.kind === "api_key") return credential.token;
66
+ return "no-key";
67
+ }
package/src/ai/sse.ts ADDED
@@ -0,0 +1,46 @@
1
+ export async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
2
+ const reader = stream.getReader();
3
+ const decoder = new TextDecoder("utf-8");
4
+ let buffer = "";
5
+ try {
6
+ while (true) {
7
+ const { done, value } = await reader.read();
8
+ if (done) {
9
+ break;
10
+ }
11
+ buffer += decoder.decode(value, { stream: true });
12
+ const parts = buffer.split("\n");
13
+ buffer = parts.pop() ?? "";
14
+ for (const part of parts) {
15
+ const line = part.endsWith("\r") ? part.slice(0, -1) : part;
16
+ if (line !== "") {
17
+ yield line;
18
+ }
19
+ }
20
+ }
21
+ buffer += decoder.decode();
22
+ const parts = buffer.split("\n");
23
+ for (const part of parts) {
24
+ const line = part.endsWith("\r") ? part.slice(0, -1) : part;
25
+ if (line !== "") {
26
+ yield line;
27
+ }
28
+ }
29
+ } finally {
30
+ reader.releaseLock();
31
+ }
32
+ }
33
+
34
+ export async function* readSse(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
35
+ for await (const line of readLines(stream)) {
36
+ if (line.startsWith("data:")) {
37
+ let data = line.slice(5);
38
+ if (data.startsWith(" ")) {
39
+ data = data.slice(1);
40
+ }
41
+ if (data !== "[DONE]") {
42
+ yield data;
43
+ }
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,37 @@
1
+ import type { Credential } from "../auth";
2
+
3
+ export type ProviderName = "anthropic" | "openai" | "gemini" | "ollama";
4
+
5
+ export interface Message {
6
+ role: "system" | "user" | "assistant";
7
+ content: string;
8
+ }
9
+
10
+ export interface Usage {
11
+ inputTokens?: number;
12
+ outputTokens?: number;
13
+ /** Generation duration in ms, when the provider reports it. */
14
+ durationMs?: number;
15
+ }
16
+
17
+ export interface CallOptions {
18
+ model: string;
19
+ systemPrompt?: string;
20
+ temperature?: number;
21
+ maxTokens?: number;
22
+ jsonMode?: boolean;
23
+ /** Per-call base URL override (OpenAI-compat / Ollama). */
24
+ baseUrl?: string;
25
+ /** Optional sink for provider-reported token usage. */
26
+ onUsage?: (usage: Usage) => void;
27
+ /** Abort in-flight provider requests (Ctrl-C / timeout / supersede). */
28
+ signal?: AbortSignal;
29
+ }
30
+
31
+ export interface ProviderAdapter {
32
+ readonly name: ProviderName;
33
+ /** Local providers ignore the credential argument; cloud adapters require it. */
34
+ call(messages: Message[], options: CallOptions, credential: Credential): Promise<string>;
35
+ /** Optional token streaming. Yields text deltas; concatenation equals the `call()` result. */
36
+ stream?(messages: Message[], options: CallOptions, credential: Credential): AsyncIterable<string>;
37
+ }