codeshark-cli 0.1.1
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/LICENSE +21 -0
- package/README.md +260 -0
- package/TERMS.md +72 -0
- package/dist/agent.js +77 -0
- package/dist/ansi.js +52 -0
- package/dist/banner.js +94 -0
- package/dist/config.js +126 -0
- package/dist/index.js +190 -0
- package/dist/keysPage.js +233 -0
- package/dist/loading.js +63 -0
- package/dist/models.js +54 -0
- package/dist/project.js +56 -0
- package/dist/provider/gateway.js +19 -0
- package/dist/provider/gemini.js +184 -0
- package/dist/provider/index.js +94 -0
- package/dist/provider/nvidia.js +19 -0
- package/dist/provider/ollama.js +22 -0
- package/dist/provider/openaiCompat.js +198 -0
- package/dist/provider/openrouter.js +21 -0
- package/dist/provider/types.js +35 -0
- package/dist/provider/unorouter.js +25 -0
- package/dist/repl.js +202 -0
- package/dist/setup.js +178 -0
- package/dist/system.js +21 -0
- package/dist/terms.js +18 -0
- package/dist/tools/files.js +288 -0
- package/dist/tools/index.js +16 -0
- package/dist/tools/registry.js +34 -0
- package/dist/tools/search.js +154 -0
- package/dist/tools/shell.js +105 -0
- package/package.json +54 -0
package/dist/project.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
export class ProjectFolderError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ProjectFolderError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Confirm that the selected working directory is a real folder. */
|
|
10
|
+
export function requireProjectFolder(folder = process.cwd()) {
|
|
11
|
+
const resolved = resolve(folder);
|
|
12
|
+
const stat = statSync(resolved, { throwIfNoEntry: false });
|
|
13
|
+
if (!stat) {
|
|
14
|
+
throw new ProjectFolderError(`Project folder does not exist: ${resolved}`);
|
|
15
|
+
}
|
|
16
|
+
if (!stat.isDirectory()) {
|
|
17
|
+
throw new ProjectFolderError(`CodeShark needs a folder, not a file: ${resolved}`);
|
|
18
|
+
}
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
/** Resolve a path while keeping the agent inside its selected project folder. */
|
|
22
|
+
export function resolveProjectPath(input, cwd) {
|
|
23
|
+
const root = resolve(cwd);
|
|
24
|
+
const target = isAbsolute(input) ? resolve(input) : resolve(root, input);
|
|
25
|
+
const rel = relative(root, target);
|
|
26
|
+
if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)))
|
|
27
|
+
return target;
|
|
28
|
+
throw new ProjectFolderError(`Path escapes the project folder: ${input}`);
|
|
29
|
+
}
|
|
30
|
+
/** Change into an explicitly selected project folder before starting CodeShark. */
|
|
31
|
+
export function openProjectFolder(folder) {
|
|
32
|
+
const resolved = requireProjectFolder(folder);
|
|
33
|
+
process.chdir(resolved);
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
/** Parse --folder / --cwd without treating normal prompt text as a path. */
|
|
37
|
+
export function extractFolderArg(args) {
|
|
38
|
+
const remaining = [];
|
|
39
|
+
let folder;
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const arg = args[i];
|
|
42
|
+
if (arg === "--folder" || arg === "--cwd") {
|
|
43
|
+
const next = args[++i];
|
|
44
|
+
// No path given: default to the current folder instead of failing,
|
|
45
|
+
// so `codeshark --folder` just works from inside the project.
|
|
46
|
+
folder = next ? (isAbsolute(next) ? next : resolve(process.cwd(), next)) : process.cwd();
|
|
47
|
+
}
|
|
48
|
+
else if (arg.startsWith("--folder=") || arg.startsWith("--cwd=")) {
|
|
49
|
+
folder = arg.slice(arg.indexOf("=") + 1);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
remaining.push(arg);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { args: remaining, folder };
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createOpenAICompatClient } from "./openaiCompat.js";
|
|
2
|
+
import { DEFAULT_GATEWAY_URL, effectiveModel } from "../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* The community gateway gives CodeShark its "works out of the box" default:
|
|
5
|
+
* end users need no API key. The gateway (see worker/) holds keys server-side
|
|
6
|
+
* and enforces `:free` lanes only. Fork the repo and deploy your own gateway,
|
|
7
|
+
* then point CODESHARK_CONFIG / gatewayUrl at it.
|
|
8
|
+
*/
|
|
9
|
+
export function createGatewayClient(cfg) {
|
|
10
|
+
const base = cfg.gatewayUrl ?? process.env.CODESHARK_GATEWAY_URL ?? DEFAULT_GATEWAY_URL;
|
|
11
|
+
const model = effectiveModel(cfg, "gateway");
|
|
12
|
+
return createOpenAICompatClient({
|
|
13
|
+
provider: "gateway",
|
|
14
|
+
baseUrl: base,
|
|
15
|
+
model,
|
|
16
|
+
apiKey: cfg.gatewayKey ?? process.env.CODESHARK_GATEWAY_KEY,
|
|
17
|
+
isFree: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ProviderError, classifyStatus, errorMessage, } from "./types.js";
|
|
2
|
+
import { effectiveModel } from "../config.js";
|
|
3
|
+
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
|
4
|
+
function toGeminiContents(messages) {
|
|
5
|
+
const systemParts = [];
|
|
6
|
+
const contents = [];
|
|
7
|
+
for (const m of messages) {
|
|
8
|
+
switch (m.role) {
|
|
9
|
+
case "system":
|
|
10
|
+
systemParts.push(m.content);
|
|
11
|
+
break;
|
|
12
|
+
case "user":
|
|
13
|
+
contents.push({ role: "user", parts: [{ text: m.content }] });
|
|
14
|
+
break;
|
|
15
|
+
case "assistant": {
|
|
16
|
+
const parts = [];
|
|
17
|
+
if (m.content)
|
|
18
|
+
parts.push({ text: m.content });
|
|
19
|
+
for (const tc of m.toolCalls ?? []) {
|
|
20
|
+
parts.push({ functionCall: { name: tc.name, args: tc.args } });
|
|
21
|
+
}
|
|
22
|
+
contents.push({ role: "model", parts });
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
case "tool": {
|
|
26
|
+
contents.push({
|
|
27
|
+
role: "user",
|
|
28
|
+
parts: [
|
|
29
|
+
{
|
|
30
|
+
functionResponse: {
|
|
31
|
+
name: m.toolName ?? "unknown",
|
|
32
|
+
response: { result: m.content, isError: m.isError ?? false },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { system: systemParts.join("\n\n") || undefined, contents };
|
|
42
|
+
}
|
|
43
|
+
function toGeminiTools(tools) {
|
|
44
|
+
if (!tools.length)
|
|
45
|
+
return [];
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
functionDeclarations: tools.map((t) => ({
|
|
49
|
+
name: t.name,
|
|
50
|
+
description: t.description,
|
|
51
|
+
parameters: t.inputSchema,
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
export function createGeminiClient(cfg, apiKey) {
|
|
57
|
+
const model = effectiveModel(cfg, "gemini");
|
|
58
|
+
const base = cfg.geminiBaseUrl ?? GEMINI_BASE_URL;
|
|
59
|
+
const endpoint = `${base.replace(/\/+$/, "")}/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
|
|
60
|
+
return {
|
|
61
|
+
provider: "gemini",
|
|
62
|
+
model,
|
|
63
|
+
isFree: true,
|
|
64
|
+
async chat(messages, tools, events, signal) {
|
|
65
|
+
const { system, contents } = toGeminiContents(messages);
|
|
66
|
+
const body = {
|
|
67
|
+
contents,
|
|
68
|
+
generationConfig: { temperature: 0.3 },
|
|
69
|
+
};
|
|
70
|
+
if (system)
|
|
71
|
+
body.systemInstruction = { parts: [{ text: system }] };
|
|
72
|
+
const gTools = toGeminiTools(tools);
|
|
73
|
+
if (gTools.length)
|
|
74
|
+
body.tools = gTools;
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await fetch(endpoint, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "content-type": "application/json" },
|
|
80
|
+
body: JSON.stringify(body),
|
|
81
|
+
signal,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
throw new ProviderError(`Cannot reach gemini: ${errorMessage(e)}`, "network");
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
let detail = "";
|
|
89
|
+
try {
|
|
90
|
+
const j = (await res.json());
|
|
91
|
+
detail = j.error?.message ?? "";
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// ignore
|
|
95
|
+
}
|
|
96
|
+
throw classifyStatus(res.status, "gemini", detail);
|
|
97
|
+
}
|
|
98
|
+
if (!res.body)
|
|
99
|
+
throw new ProviderError("gemini: empty response body", "unknown");
|
|
100
|
+
return parseGeminiStream(res.body, model, events);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async function parseGeminiStream(body, model, events) {
|
|
105
|
+
const reader = body.getReader();
|
|
106
|
+
const decoder = new TextDecoder();
|
|
107
|
+
let buffer = "";
|
|
108
|
+
let text = "";
|
|
109
|
+
const toolCalls = [];
|
|
110
|
+
let truncated = false;
|
|
111
|
+
const handleData = (data) => {
|
|
112
|
+
if (!data)
|
|
113
|
+
return;
|
|
114
|
+
let json;
|
|
115
|
+
try {
|
|
116
|
+
json = JSON.parse(data);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (json.error) {
|
|
122
|
+
const err = json.error;
|
|
123
|
+
const status = typeof json.code === "number" ? json.code : 400;
|
|
124
|
+
throw classifyStatus(status, "gemini", err.message ?? "stream error");
|
|
125
|
+
}
|
|
126
|
+
const candidates = json.candidates;
|
|
127
|
+
const candidate = candidates?.[0];
|
|
128
|
+
if (!candidate?.content)
|
|
129
|
+
return;
|
|
130
|
+
for (const part of candidate.content.parts ?? []) {
|
|
131
|
+
if (part.text) {
|
|
132
|
+
text += part.text;
|
|
133
|
+
events?.onText?.(part.text);
|
|
134
|
+
}
|
|
135
|
+
if (part.functionCall) {
|
|
136
|
+
toolCalls.push({
|
|
137
|
+
id: `fc_${part.functionCall.name}_${toolCalls.length}`,
|
|
138
|
+
name: part.functionCall.name,
|
|
139
|
+
args: part.functionCall.args ?? {},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (candidate.finishReason === "MAX_TOKENS")
|
|
144
|
+
truncated = true;
|
|
145
|
+
if (candidate.finishReason === "SAFETY") {
|
|
146
|
+
throw new ProviderError("gemini: response blocked by safety filter", "unknown");
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
while (true) {
|
|
151
|
+
const { done, value } = await reader.read();
|
|
152
|
+
if (done)
|
|
153
|
+
break;
|
|
154
|
+
buffer += decoder.decode(value, { stream: true });
|
|
155
|
+
const lines = buffer.split("\n");
|
|
156
|
+
buffer = lines.pop() ?? "";
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (trimmed.startsWith("data:"))
|
|
160
|
+
handleData(trimmed.slice(5).trim());
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (buffer.trim()) {
|
|
164
|
+
const trimmed = buffer.trim();
|
|
165
|
+
if (trimmed.startsWith("data:"))
|
|
166
|
+
handleData(trimmed.slice(5).trim());
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
if (e instanceof ProviderError)
|
|
171
|
+
throw e;
|
|
172
|
+
throw new ProviderError(`gemini: stream interrupted: ${errorMessage(e)}`, "network");
|
|
173
|
+
}
|
|
174
|
+
if (truncated) {
|
|
175
|
+
events?.onDebug?.(`gemini: response hit MAX_TOKENS and was truncated.`);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
role: "assistant",
|
|
179
|
+
content: text.trim(),
|
|
180
|
+
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Exported for tests. */
|
|
184
|
+
export { toGeminiContents, toGeminiTools };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { activeProvider, envApiKey } from "../config.js";
|
|
2
|
+
import { createGatewayClient } from "./gateway.js";
|
|
3
|
+
import { createGeminiClient } from "./gemini.js";
|
|
4
|
+
import { createNvidiaClient } from "./nvidia.js";
|
|
5
|
+
import { createOllamaClient } from "./ollama.js";
|
|
6
|
+
import { createOpenRouterClient } from "./openrouter.js";
|
|
7
|
+
import { createUnoRouterClient } from "./unorouter.js";
|
|
8
|
+
/**
|
|
9
|
+
* Build the ordered list of ChatClients for this machine:
|
|
10
|
+
* the active model's provider first, then automatic free fallbacks
|
|
11
|
+
* (any other configured keys, then the zero-setup community gateway).
|
|
12
|
+
*/
|
|
13
|
+
export function resolveClients(cfg, debug) {
|
|
14
|
+
const clients = [];
|
|
15
|
+
const add = (c) => {
|
|
16
|
+
if (!clients.some((x) => x.provider === c.provider))
|
|
17
|
+
clients.push(c);
|
|
18
|
+
};
|
|
19
|
+
const has = (provider) => clients.some((c) => c.provider === provider);
|
|
20
|
+
const primary = activeProvider(cfg);
|
|
21
|
+
switch (primary) {
|
|
22
|
+
case "openrouter": {
|
|
23
|
+
const key = cfg.openrouterApiKey ?? envApiKey("openrouter");
|
|
24
|
+
// With no key, an OpenRouter model still runs through the gateway.
|
|
25
|
+
if (key)
|
|
26
|
+
add(createOpenRouterClient(cfg, key));
|
|
27
|
+
else
|
|
28
|
+
add(createGatewayClient(cfg));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case "unorouter": {
|
|
32
|
+
const key = cfg.unorouterApiKey ?? envApiKey("unorouter");
|
|
33
|
+
// With no key, an UnoRouter model still runs through the gateway
|
|
34
|
+
// (the gateway holds the free UnoRouter key server-side).
|
|
35
|
+
if (key)
|
|
36
|
+
add(createUnoRouterClient(cfg, key));
|
|
37
|
+
else
|
|
38
|
+
add(createGatewayClient(cfg));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "nvidia": {
|
|
42
|
+
const key = cfg.nvidiaApiKey ?? envApiKey("nvidia");
|
|
43
|
+
// NVIDIA-hosted models can't run through the OpenRouter gateway.
|
|
44
|
+
if (key)
|
|
45
|
+
add(createNvidiaClient(cfg, key));
|
|
46
|
+
else
|
|
47
|
+
debug?.("NVIDIA model selected but no NVIDIA_API_KEY found — run `codeshark setup`.");
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "gemini": {
|
|
51
|
+
const key = cfg.geminiApiKey ?? envApiKey("gemini");
|
|
52
|
+
if (key)
|
|
53
|
+
add(createGeminiClient(cfg, key));
|
|
54
|
+
else
|
|
55
|
+
debug?.("No Gemini API key env found (GEMINI_API_KEY) — will fall back.");
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "ollama":
|
|
59
|
+
add(createOllamaClient(cfg));
|
|
60
|
+
break;
|
|
61
|
+
case "gateway":
|
|
62
|
+
default:
|
|
63
|
+
add(createGatewayClient(cfg));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
// Automatic free fallbacks, deduped: any other provider you have a key for.
|
|
67
|
+
if (!has("openrouter")) {
|
|
68
|
+
const key = cfg.openrouterApiKey ?? envApiKey("openrouter");
|
|
69
|
+
if (key)
|
|
70
|
+
add(createOpenRouterClient(cfg, key));
|
|
71
|
+
}
|
|
72
|
+
if (!has("unorouter")) {
|
|
73
|
+
const key = cfg.unorouterApiKey ?? envApiKey("unorouter");
|
|
74
|
+
if (key)
|
|
75
|
+
add(createUnoRouterClient(cfg, key));
|
|
76
|
+
}
|
|
77
|
+
if (!has("nvidia")) {
|
|
78
|
+
const key = cfg.nvidiaApiKey ?? envApiKey("nvidia");
|
|
79
|
+
if (key)
|
|
80
|
+
add(createNvidiaClient(cfg, key));
|
|
81
|
+
}
|
|
82
|
+
if (!has("gemini")) {
|
|
83
|
+
const key = cfg.geminiApiKey ?? envApiKey("gemini");
|
|
84
|
+
if (key)
|
|
85
|
+
add(createGeminiClient(cfg, key));
|
|
86
|
+
}
|
|
87
|
+
if (!has("ollama") && (cfg.ollamaBaseUrl || process.env.CODESHARK_OLLAMA_URL)) {
|
|
88
|
+
add(createOllamaClient(cfg));
|
|
89
|
+
}
|
|
90
|
+
if (!has("gateway") && primary !== "ollama") {
|
|
91
|
+
add(createGatewayClient(cfg));
|
|
92
|
+
}
|
|
93
|
+
return clients;
|
|
94
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createOpenAICompatClient } from "./openaiCompat.js";
|
|
2
|
+
import { effectiveModel } from "../config.js";
|
|
3
|
+
import { toApiSlug } from "../models.js";
|
|
4
|
+
export const DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
|
5
|
+
/**
|
|
6
|
+
* NVIDIA NIM hosts open models (DeepSeek, Kimi, GLM, Nemotron, …) behind a
|
|
7
|
+
* free, OpenAI-compatible API. Get a key at https://build.nvidia.com —
|
|
8
|
+
* no credit card, keys start with `nvapi-`.
|
|
9
|
+
*/
|
|
10
|
+
export function createNvidiaClient(cfg, apiKey) {
|
|
11
|
+
const model = toApiSlug(effectiveModel(cfg, "nvidia"));
|
|
12
|
+
return createOpenAICompatClient({
|
|
13
|
+
provider: "nvidia",
|
|
14
|
+
baseUrl: cfg.nvidiaBaseUrl ?? DEFAULT_NVIDIA_BASE_URL,
|
|
15
|
+
model,
|
|
16
|
+
apiKey,
|
|
17
|
+
isFree: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createOpenAICompatClient } from "./openaiCompat.js";
|
|
2
|
+
import { effectiveModel } from "../config.js";
|
|
3
|
+
export const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
|
|
4
|
+
export function createOllamaClient(cfg) {
|
|
5
|
+
const model = effectiveModel(cfg, "ollama");
|
|
6
|
+
return createOpenAICompatClient({
|
|
7
|
+
provider: "ollama",
|
|
8
|
+
baseUrl: cfg.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE_URL,
|
|
9
|
+
model,
|
|
10
|
+
isFree: true,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Quick reachability check for the local Ollama server. */
|
|
14
|
+
export async function ollamaAvailable(cfg) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${(cfg.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE_URL).replace(/\/+$/, "")}/models`);
|
|
17
|
+
return res.ok;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { ProviderError, classifyStatus, errorMessage, } from "./types.js";
|
|
2
|
+
function toOpenAIMessages(messages) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const m of messages) {
|
|
5
|
+
switch (m.role) {
|
|
6
|
+
case "system":
|
|
7
|
+
case "user":
|
|
8
|
+
out.push({ role: m.role, content: m.content });
|
|
9
|
+
break;
|
|
10
|
+
case "assistant": {
|
|
11
|
+
const msg = { role: "assistant", content: m.content };
|
|
12
|
+
if (m.toolCalls?.length) {
|
|
13
|
+
msg.tool_calls = m.toolCalls.map((tc) => ({
|
|
14
|
+
id: tc.id,
|
|
15
|
+
type: "function",
|
|
16
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
out.push(msg);
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
case "tool":
|
|
23
|
+
out.push({ role: "tool", content: m.content, tool_call_id: m.toolCallId });
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function toOpenAITools(tools) {
|
|
30
|
+
return tools.map((t) => ({
|
|
31
|
+
type: "function",
|
|
32
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function safeParseArgs(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return {};
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function createOpenAICompatClient(opts) {
|
|
47
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
48
|
+
const endpoint = `${base}/chat/completions`;
|
|
49
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
50
|
+
return {
|
|
51
|
+
provider: opts.provider,
|
|
52
|
+
model: opts.model,
|
|
53
|
+
isFree: opts.isFree ?? true,
|
|
54
|
+
async chat(messages, tools, events, signal) {
|
|
55
|
+
const body = {
|
|
56
|
+
model: opts.model,
|
|
57
|
+
messages: toOpenAIMessages(messages),
|
|
58
|
+
stream: true,
|
|
59
|
+
temperature: 0.3,
|
|
60
|
+
};
|
|
61
|
+
if (tools.length)
|
|
62
|
+
body.tools = toOpenAITools(tools);
|
|
63
|
+
const headers = {
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
|
|
66
|
+
...opts.extraHeaders,
|
|
67
|
+
};
|
|
68
|
+
// Wait out rate limits with backoff before surfacing a 429 — the
|
|
69
|
+
// queue lives client-side too, so shared gateway lanes feel smooth.
|
|
70
|
+
const retryDelays = opts.rateLimitRetryDelays ?? [800, 1600];
|
|
71
|
+
let res;
|
|
72
|
+
for (let attempt = 0;; attempt++) {
|
|
73
|
+
let candidate;
|
|
74
|
+
try {
|
|
75
|
+
candidate = await fetchImpl(endpoint, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers,
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
throw new ProviderError(`Cannot reach ${opts.provider} at ${base}: ${errorMessage(e)}`, "network");
|
|
84
|
+
}
|
|
85
|
+
if (candidate.status !== 429 || attempt >= retryDelays.length) {
|
|
86
|
+
res = candidate;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
await new Promise((r) => setTimeout(r, retryDelays[attempt]));
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
let detail = "";
|
|
93
|
+
try {
|
|
94
|
+
const j = (await res.json());
|
|
95
|
+
detail = j.error?.message ?? JSON.stringify(j).slice(0, 300);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
99
|
+
}
|
|
100
|
+
throw classifyStatus(res.status, opts.provider, detail);
|
|
101
|
+
}
|
|
102
|
+
if (!res.body) {
|
|
103
|
+
throw new ProviderError(`${opts.provider}: empty response body`, "unknown");
|
|
104
|
+
}
|
|
105
|
+
return parseOpenAIStream(res.body, opts.provider, events);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function parseOpenAIStream(body, provider, events) {
|
|
110
|
+
const reader = body.getReader();
|
|
111
|
+
const decoder = new TextDecoder();
|
|
112
|
+
let buffer = "";
|
|
113
|
+
let content = "";
|
|
114
|
+
const pending = new Map();
|
|
115
|
+
const finalized = new Map();
|
|
116
|
+
const handleData = (data) => {
|
|
117
|
+
if (!data || data === "[DONE]")
|
|
118
|
+
return;
|
|
119
|
+
let json;
|
|
120
|
+
try {
|
|
121
|
+
json = JSON.parse(data);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (json.error) {
|
|
127
|
+
const err = json.error;
|
|
128
|
+
const status = typeof json.status === "number" ? json.status : 400;
|
|
129
|
+
throw classifyStatus(status, provider, err.message ?? "stream error");
|
|
130
|
+
}
|
|
131
|
+
const choices = json.choices;
|
|
132
|
+
const choice = choices?.[0];
|
|
133
|
+
if (!choice)
|
|
134
|
+
return;
|
|
135
|
+
const delta = choice.delta ?? {};
|
|
136
|
+
if (delta.content) {
|
|
137
|
+
content += delta.content;
|
|
138
|
+
events?.onText?.(delta.content);
|
|
139
|
+
}
|
|
140
|
+
if (delta.tool_calls) {
|
|
141
|
+
for (const tc of delta.tool_calls) {
|
|
142
|
+
const idx = tc.index ?? 0;
|
|
143
|
+
const cur = pending.get(idx) ?? { id: "", name: "", args: "" };
|
|
144
|
+
if (tc.id)
|
|
145
|
+
cur.id = tc.id;
|
|
146
|
+
if (tc.function?.name)
|
|
147
|
+
cur.name += tc.function.name;
|
|
148
|
+
if (tc.function?.arguments)
|
|
149
|
+
cur.args += tc.function.arguments;
|
|
150
|
+
pending.set(idx, cur);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (choice.finish_reason) {
|
|
154
|
+
for (const [idx, tc] of pending)
|
|
155
|
+
finalized.set(idx, tc);
|
|
156
|
+
pending.clear();
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
while (true) {
|
|
161
|
+
const { done, value } = await reader.read();
|
|
162
|
+
if (done)
|
|
163
|
+
break;
|
|
164
|
+
buffer += decoder.decode(value, { stream: true });
|
|
165
|
+
const lines = buffer.split("\n");
|
|
166
|
+
buffer = lines.pop() ?? "";
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
const trimmed = line.trim();
|
|
169
|
+
if (trimmed.startsWith("data:"))
|
|
170
|
+
handleData(trimmed.slice(5).trim());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (buffer.trim()) {
|
|
174
|
+
const trimmed = buffer.trim();
|
|
175
|
+
if (trimmed.startsWith("data:"))
|
|
176
|
+
handleData(trimmed.slice(5).trim());
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (e instanceof ProviderError)
|
|
181
|
+
throw e;
|
|
182
|
+
throw new ProviderError(`${provider}: stream interrupted: ${errorMessage(e)}`, "network");
|
|
183
|
+
}
|
|
184
|
+
const toolCalls = [...finalized.values()]
|
|
185
|
+
.filter((c) => c.name)
|
|
186
|
+
.map((c) => ({
|
|
187
|
+
id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`,
|
|
188
|
+
name: c.name,
|
|
189
|
+
args: safeParseArgs(c.args),
|
|
190
|
+
}));
|
|
191
|
+
return {
|
|
192
|
+
role: "assistant",
|
|
193
|
+
content: content.trim(),
|
|
194
|
+
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Exported for tests. */
|
|
198
|
+
export { toOpenAIMessages, toOpenAITools, safeParseArgs };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createOpenAICompatClient } from "./openaiCompat.js";
|
|
2
|
+
import { effectiveModel } from "../config.js";
|
|
3
|
+
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
4
|
+
/** True when the model slug only targets free models on OpenRouter. */
|
|
5
|
+
export function isOpenRouterFreeModel(model) {
|
|
6
|
+
return model === "openrouter/free" || model.endsWith(":free");
|
|
7
|
+
}
|
|
8
|
+
export function createOpenRouterClient(cfg, apiKey) {
|
|
9
|
+
const model = effectiveModel(cfg, "openrouter");
|
|
10
|
+
return createOpenAICompatClient({
|
|
11
|
+
provider: "openrouter",
|
|
12
|
+
baseUrl: cfg.openrouterBaseUrl ?? OPENROUTER_BASE_URL,
|
|
13
|
+
model,
|
|
14
|
+
apiKey,
|
|
15
|
+
isFree: isOpenRouterFreeModel(model),
|
|
16
|
+
extraHeaders: {
|
|
17
|
+
"HTTP-Referer": "https://github.com/codeshark/codeshark",
|
|
18
|
+
"X-Title": "CodeShark",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical message/tool types shared across every provider adapter.
|
|
3
|
+
* Adapters convert between this format and their own wire format.
|
|
4
|
+
*/
|
|
5
|
+
export class ProviderError extends Error {
|
|
6
|
+
kind;
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, kind = "unknown", status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ProviderError";
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function errorMessage(e) {
|
|
16
|
+
return e instanceof Error ? e.message : String(e);
|
|
17
|
+
}
|
|
18
|
+
/** Best-effort mapping of HTTP status codes to a friendly error kind. */
|
|
19
|
+
export function classifyStatus(status, provider, detail) {
|
|
20
|
+
switch (status) {
|
|
21
|
+
case 401:
|
|
22
|
+
case 403:
|
|
23
|
+
return new ProviderError(`${provider}: authentication failed${detail ? ` (${detail})` : ""}. Check your API key (run \`codeshark setup\`).`, "auth", status);
|
|
24
|
+
case 404:
|
|
25
|
+
case 400:
|
|
26
|
+
return new ProviderError(`${provider}: model or request rejected${detail ? ` (${detail})` : ""}. Try \`codeshark model\` to see the active model.`, "model", status);
|
|
27
|
+
case 429:
|
|
28
|
+
return new ProviderError(`${provider}: rate limit hit on shared lanes — wait a moment or add your own key (run \`codeshark setup\`).`, "rate_limit", status);
|
|
29
|
+
default:
|
|
30
|
+
if (status >= 500) {
|
|
31
|
+
return new ProviderError(`${provider}: server error (${status}). Try again shortly.`, "server", status);
|
|
32
|
+
}
|
|
33
|
+
return new ProviderError(`${provider}: request failed (${status})${detail ? `: ${detail}` : ""}.`, "unknown", status);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createOpenAICompatClient } from "./openaiCompat.js";
|
|
2
|
+
import { effectiveModel } from "../config.js";
|
|
3
|
+
export const UNOROUTER_BASE_URL = "https://api.unorouter.com/v1";
|
|
4
|
+
/**
|
|
5
|
+
* True when the model slug only targets free models on UnoRouter.
|
|
6
|
+
* UnoRouter's free lanes are OpenAI-compatible: one key, 200+ free models
|
|
7
|
+
* behind `:free` slugs (https://unorouter.com).
|
|
8
|
+
*/
|
|
9
|
+
export function isUnoRouterFreeModel(model) {
|
|
10
|
+
return model === "unorouter/free" || model.endsWith(":free");
|
|
11
|
+
}
|
|
12
|
+
export function createUnoRouterClient(cfg, apiKey) {
|
|
13
|
+
const model = effectiveModel(cfg, "unorouter");
|
|
14
|
+
return createOpenAICompatClient({
|
|
15
|
+
provider: "unorouter",
|
|
16
|
+
baseUrl: cfg.unorouterBaseUrl ?? UNOROUTER_BASE_URL,
|
|
17
|
+
model,
|
|
18
|
+
apiKey,
|
|
19
|
+
isFree: isUnoRouterFreeModel(model),
|
|
20
|
+
extraHeaders: {
|
|
21
|
+
"HTTP-Referer": "https://github.com/codeshark/codeshark",
|
|
22
|
+
"X-Title": "CodeShark",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|