novacode 0.2.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.
- package/LICENSE +201 -0
- package/README.md +89 -0
- package/package.json +56 -0
- package/src/agent/agent.ts +87 -0
- package/src/agent/loop.ts +218 -0
- package/src/agent/prompt.ts +50 -0
- package/src/commands/compact.ts +28 -0
- package/src/commands/index.ts +62 -0
- package/src/commands/models.ts +86 -0
- package/src/commands/providers.ts +222 -0
- package/src/commands/session.ts +40 -0
- package/src/config/providers.ts +199 -0
- package/src/config/store.ts +67 -0
- package/src/main.ts +169 -0
- package/src/onboarding/wizard.ts +58 -0
- package/src/provider/gemini.ts +254 -0
- package/src/provider/openai.ts +218 -0
- package/src/provider/registry.ts +62 -0
- package/src/provider/stream.ts +77 -0
- package/src/session/compact.ts +126 -0
- package/src/session/store.ts +206 -0
- package/src/tools/fs.ts +195 -0
- package/src/tools/git.ts +82 -0
- package/src/tools/index.ts +33 -0
- package/src/tools/search.ts +252 -0
- package/src/tools/shell.ts +89 -0
- package/src/tools/web.ts +239 -0
- package/src/tui/app.tsx +517 -0
- package/src/tui/markdown.ts +62 -0
- package/src/tui/print.ts +75 -0
- package/src/types.ts +233 -0
- package/src/util.ts +88 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point for the novacode CLI.
|
|
3
|
+
* Handles configuration, CLI flags, and switches between interactive/print modes.
|
|
4
|
+
*/
|
|
5
|
+
import { parseArgs } from "node:util"
|
|
6
|
+
import { Agent } from "./agent/agent.ts"
|
|
7
|
+
import { buildSystemPrompt } from "./agent/prompt.ts"
|
|
8
|
+
import { handleSessionCommand } from "./commands/session.ts"
|
|
9
|
+
import { getProvider, MODELS } from "./config/providers.ts"
|
|
10
|
+
import { configExists, loadAuth, loadConfig } from "./config/store.ts"
|
|
11
|
+
import { runOnboarding } from "./onboarding/wizard.ts"
|
|
12
|
+
import { getSessionStore } from "./session/store.ts"
|
|
13
|
+
import { getAllTools } from "./tools/index.ts"
|
|
14
|
+
import { runPrintMode } from "./tui/print.ts"
|
|
15
|
+
|
|
16
|
+
// Ensure providers are registered
|
|
17
|
+
import "./provider/openai.ts"
|
|
18
|
+
import "./provider/gemini.ts"
|
|
19
|
+
|
|
20
|
+
function parseCli() {
|
|
21
|
+
const { values, positionals } = parseArgs({
|
|
22
|
+
options: {
|
|
23
|
+
help: { type: "boolean", short: "h" },
|
|
24
|
+
version: { type: "boolean", short: "v" },
|
|
25
|
+
provider: { type: "string" },
|
|
26
|
+
model: { type: "string" },
|
|
27
|
+
"api-key": { type: "string" },
|
|
28
|
+
session: { type: "string", short: "s" },
|
|
29
|
+
},
|
|
30
|
+
strict: false,
|
|
31
|
+
allowPositionals: true,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return { flags: values, args: positionals }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findModel(modelId: string, providerId?: string) {
|
|
38
|
+
return MODELS.find((m) => {
|
|
39
|
+
if (providerId) return m.provider === providerId && m.id === modelId
|
|
40
|
+
return m.id === modelId
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
const { flags, args } = parseCli()
|
|
46
|
+
|
|
47
|
+
if (flags.version) {
|
|
48
|
+
const pkg = await Bun.file("package.json").json()
|
|
49
|
+
console.log(`novacode ${pkg.version}`)
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (flags.help) {
|
|
54
|
+
console.log(`novacode — open-source coding agent
|
|
55
|
+
|
|
56
|
+
Usage:
|
|
57
|
+
novacode Interactive mode
|
|
58
|
+
novacode "prompt" Print mode (non-interactive)
|
|
59
|
+
novacode session <cmd> Session management (list, delete)
|
|
60
|
+
novacode --session <id> Resume a session
|
|
61
|
+
|
|
62
|
+
Options:
|
|
63
|
+
-h, --help Show help
|
|
64
|
+
-v, --version Show version
|
|
65
|
+
--provider <id> Provider to use
|
|
66
|
+
--model <id> Model to use
|
|
67
|
+
--api-key <key> API key override
|
|
68
|
+
-s, --session <id> Resume session by ID`)
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle session subcommand
|
|
73
|
+
if (args[0] === "session") {
|
|
74
|
+
await handleSessionCommand(args.slice(1))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const controller = new AbortController()
|
|
79
|
+
|
|
80
|
+
const onSignal = () => {
|
|
81
|
+
controller.abort()
|
|
82
|
+
process.stderr.write("\nAborted.\n")
|
|
83
|
+
process.exit(130)
|
|
84
|
+
}
|
|
85
|
+
process.on("SIGINT", onSignal)
|
|
86
|
+
process.on("SIGTERM", onSignal)
|
|
87
|
+
|
|
88
|
+
// First-run onboarding
|
|
89
|
+
const config = await ((await configExists()) ? loadConfig() : runOnboarding())
|
|
90
|
+
const auth = await loadAuth()
|
|
91
|
+
|
|
92
|
+
// CLI overrides
|
|
93
|
+
const providerId = (flags.provider as string) || config.provider
|
|
94
|
+
const modelId = (flags.model as string) || config.model
|
|
95
|
+
const apiKey = (flags["api-key"] as string) || auth.apiKeys[providerId]
|
|
96
|
+
|
|
97
|
+
const provider = getProvider(providerId)
|
|
98
|
+
if (!provider) {
|
|
99
|
+
console.error(`Unknown provider: ${providerId}`)
|
|
100
|
+
console.error(`Available: ${getProvider("glm") ? "glm, " : ""}gemini, deepseek, openai`)
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!apiKey) {
|
|
105
|
+
console.error(
|
|
106
|
+
`No API key for ${provider.name}. Set ${provider.envKey} or run novacode for onboarding.`,
|
|
107
|
+
)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const model = findModel(modelId, providerId)
|
|
112
|
+
if (!model) {
|
|
113
|
+
console.error(`Unknown model: ${modelId}`)
|
|
114
|
+
console.error("Available models:")
|
|
115
|
+
for (const m of MODELS.filter((m) => m.provider === providerId)) {
|
|
116
|
+
console.error(` ${m.id} — ${m.name}`)
|
|
117
|
+
}
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cwd = process.cwd()
|
|
122
|
+
const tools = getAllTools(cwd)
|
|
123
|
+
const system = buildSystemPrompt(cwd, tools)
|
|
124
|
+
|
|
125
|
+
// Session persistence
|
|
126
|
+
const store = getSessionStore()
|
|
127
|
+
const session = flags.session
|
|
128
|
+
? store.get(flags.session as string)
|
|
129
|
+
: store.create(cwd, model.id, providerId)
|
|
130
|
+
|
|
131
|
+
if (flags.session && !session) {
|
|
132
|
+
console.error(`Session not found: ${flags.session}`)
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sessionId = session!.id
|
|
137
|
+
const existingMessages = store.messages(sessionId)
|
|
138
|
+
|
|
139
|
+
const agent = new Agent({
|
|
140
|
+
api: provider.api,
|
|
141
|
+
model,
|
|
142
|
+
apiKey,
|
|
143
|
+
baseUrl: provider.baseUrl,
|
|
144
|
+
system,
|
|
145
|
+
tools,
|
|
146
|
+
messages: existingMessages,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Print mode: prompt provided as arg
|
|
150
|
+
const prompt = args.join(" ")
|
|
151
|
+
if (prompt) {
|
|
152
|
+
const result = await runPrintMode(agent, prompt, controller.signal)
|
|
153
|
+
if (result) {
|
|
154
|
+
store.appendMany(sessionId, result)
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Interactive TUI mode (Phase 3)
|
|
160
|
+
process.off("SIGINT", onSignal)
|
|
161
|
+
process.off("SIGTERM", onSignal)
|
|
162
|
+
const { interactive } = await import("./tui/app.tsx")
|
|
163
|
+
await interactive(agent, store, sessionId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
main().catch((e) => {
|
|
167
|
+
console.error("Fatal:", e.message)
|
|
168
|
+
process.exit(1)
|
|
169
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as clack from "@clack/prompts"
|
|
2
|
+
import { getModelsForProvider, getProvider, PROVIDERS } from "../config/providers.ts"
|
|
3
|
+
import { saveAuth, saveConfig } from "../config/store.ts"
|
|
4
|
+
import type { NovaConfig } from "../types.ts"
|
|
5
|
+
|
|
6
|
+
export async function runOnboarding(): Promise<NovaConfig> {
|
|
7
|
+
clack.intro("⚡ Nova — your coding companion")
|
|
8
|
+
|
|
9
|
+
const providerId = await clack.select({
|
|
10
|
+
message: "Pick a provider",
|
|
11
|
+
options: PROVIDERS.map((p) => ({ value: p.id, label: p.name })),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
if (clack.isCancel(providerId)) {
|
|
15
|
+
clack.cancel("Cancelled")
|
|
16
|
+
process.exit(0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const provider = getProvider(providerId as string)
|
|
20
|
+
if (!provider) {
|
|
21
|
+
clack.cancel("Unknown provider")
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const apiKey = await clack.password({
|
|
26
|
+
message: `Enter ${provider.name} API key`,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (clack.isCancel(apiKey)) {
|
|
30
|
+
clack.cancel("Cancelled")
|
|
31
|
+
process.exit(0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const models = getModelsForProvider(providerId as string)
|
|
35
|
+
const modelId = await clack.select({
|
|
36
|
+
message: "Pick a default model",
|
|
37
|
+
options: models.map((m) => ({
|
|
38
|
+
value: m.id,
|
|
39
|
+
label: `${m.name} (${(m.contextWindow / 1000).toFixed(0)}k ctx)`,
|
|
40
|
+
})),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (clack.isCancel(modelId)) {
|
|
44
|
+
clack.cancel("Cancelled")
|
|
45
|
+
process.exit(0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config: NovaConfig = {
|
|
49
|
+
provider: providerId as string,
|
|
50
|
+
model: modelId as string,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await saveConfig(config)
|
|
54
|
+
await saveAuth({ apiKeys: { [providerId as string]: apiKey as string } })
|
|
55
|
+
|
|
56
|
+
clack.note("Ready. Type your prompt or /help for commands")
|
|
57
|
+
return config
|
|
58
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantResult,
|
|
3
|
+
ContentPart,
|
|
4
|
+
Msg,
|
|
5
|
+
StopReason,
|
|
6
|
+
StreamEvent,
|
|
7
|
+
StreamFn,
|
|
8
|
+
StreamOpts,
|
|
9
|
+
ToolDef,
|
|
10
|
+
Usage,
|
|
11
|
+
} from "../types.ts"
|
|
12
|
+
import { register } from "./registry.ts"
|
|
13
|
+
import { EventStream } from "./stream.ts"
|
|
14
|
+
|
|
15
|
+
interface GeminiPart {
|
|
16
|
+
text?: string
|
|
17
|
+
thought?: boolean | string
|
|
18
|
+
inline_data?: { mime_type: string; data: string }
|
|
19
|
+
function_call?: { name: string; args: Record<string, unknown> }
|
|
20
|
+
function_response?: { name: string; response: Record<string, unknown> }
|
|
21
|
+
thought_signature?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface GeminiContent {
|
|
25
|
+
role: "user" | "model"
|
|
26
|
+
parts: GeminiPart[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Maps our internal Msg format to the Gemini 'Content' format.
|
|
31
|
+
* Groups consecutive tool_result messages into a single Gemini message.
|
|
32
|
+
*/
|
|
33
|
+
function msgsToGemini(messages: Msg[]): GeminiContent[] {
|
|
34
|
+
const contents: GeminiContent[] = []
|
|
35
|
+
|
|
36
|
+
for (const msg of messages) {
|
|
37
|
+
if (msg.role === "user") {
|
|
38
|
+
const parts: GeminiPart[] =
|
|
39
|
+
typeof msg.content === "string"
|
|
40
|
+
? [{ text: msg.content }]
|
|
41
|
+
: msg.content.map((c) => {
|
|
42
|
+
if (c.type === "text") return { text: c.text }
|
|
43
|
+
if (c.type === "image") return { inline_data: { mime_type: c.mime, data: c.data } }
|
|
44
|
+
return { text: "" }
|
|
45
|
+
})
|
|
46
|
+
contents.push({ role: "user", parts })
|
|
47
|
+
} else if (msg.role === "assistant") {
|
|
48
|
+
const parts: GeminiPart[] = msg.content.map((c) => {
|
|
49
|
+
if (c.type === "text") return { text: c.text, thought_signature: c.signature }
|
|
50
|
+
if (c.type === "thinking")
|
|
51
|
+
return { thought: true, text: c.text, thought_signature: c.signature }
|
|
52
|
+
if (c.type === "tool_call")
|
|
53
|
+
return { function_call: { name: c.name, args: c.args }, thought_signature: c.signature }
|
|
54
|
+
return { text: "" }
|
|
55
|
+
})
|
|
56
|
+
contents.push({ role: "model", parts })
|
|
57
|
+
} else if (msg.role === "tool_result") {
|
|
58
|
+
const part: GeminiPart = {
|
|
59
|
+
function_response: {
|
|
60
|
+
name: msg.tool,
|
|
61
|
+
response: {
|
|
62
|
+
content: msg.content
|
|
63
|
+
.map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
|
|
64
|
+
.join("\n"),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const last = contents[contents.length - 1]
|
|
70
|
+
// Gemini requires alternating roles; multiple function_responses group into one 'user' message.
|
|
71
|
+
if (last && last.role === "user" && last.parts.some((p) => p.function_response)) {
|
|
72
|
+
last.parts.push(part)
|
|
73
|
+
} else {
|
|
74
|
+
contents.push({ role: "user", parts: [part] })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return contents
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toolsToGemini(tools: ToolDef[]): unknown[] {
|
|
83
|
+
if (tools.length === 0) return []
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
function_declarations: tools.map((t) => ({
|
|
87
|
+
name: t.name,
|
|
88
|
+
description: t.description,
|
|
89
|
+
parameters: t.parameters,
|
|
90
|
+
})),
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const streamGemini: StreamFn = (
|
|
96
|
+
opts: StreamOpts,
|
|
97
|
+
): EventStream<StreamEvent, AssistantResult> => {
|
|
98
|
+
const es = new EventStream<StreamEvent, AssistantResult>()
|
|
99
|
+
|
|
100
|
+
;(async () => {
|
|
101
|
+
try {
|
|
102
|
+
const baseUrl = opts.baseUrl || "https://generativelanguage.googleapis.com"
|
|
103
|
+
const url = `${baseUrl}/v1beta/models/${opts.model.id}:streamGenerateContent?alt=sse&key=${opts.apiKey}`
|
|
104
|
+
|
|
105
|
+
const body = {
|
|
106
|
+
contents: msgsToGemini(opts.messages),
|
|
107
|
+
system_instruction: opts.system ? { parts: [{ text: opts.system }] } : undefined,
|
|
108
|
+
tools: opts.tools.length > 0 ? toolsToGemini(opts.tools) : undefined,
|
|
109
|
+
generationConfig: {
|
|
110
|
+
thinkingConfig: opts.model.supportsThinking ? { thinkingLevel: "low" } : undefined,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const response = await fetch(url, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"Api-Revision": "2026-05-20",
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
signal: opts.signal,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
const text = await response.text()
|
|
126
|
+
let msg = text
|
|
127
|
+
try {
|
|
128
|
+
const json = JSON.parse(text)
|
|
129
|
+
msg = json.error?.message || json.message || text
|
|
130
|
+
} catch {
|
|
131
|
+
/* use raw text */
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const errorMsg = `Gemini Error (${response.status}): ${msg}`
|
|
135
|
+
es.push({ type: "text_delta", text: errorMsg })
|
|
136
|
+
es.finish({
|
|
137
|
+
content: [{ type: "text", text: errorMsg }],
|
|
138
|
+
usage: { in: 0, out: 0 },
|
|
139
|
+
stop: "error",
|
|
140
|
+
})
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const reader = response.body?.getReader()
|
|
145
|
+
if (!reader) {
|
|
146
|
+
es.finish({ content: [], usage: { in: 0, out: 0 }, stop: "error" })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const decoder = new TextDecoder()
|
|
151
|
+
let buffer = ""
|
|
152
|
+
let usage: Usage = { in: 0, out: 0 }
|
|
153
|
+
let stop: StopReason = "stop"
|
|
154
|
+
const content: ContentPart[] = []
|
|
155
|
+
|
|
156
|
+
while (true) {
|
|
157
|
+
const { done, value } = await reader.read()
|
|
158
|
+
if (done) break
|
|
159
|
+
|
|
160
|
+
buffer += decoder.decode(value, { stream: true })
|
|
161
|
+
const lines = buffer.split("\n")
|
|
162
|
+
buffer = lines.pop() ?? ""
|
|
163
|
+
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
const trimmed = line.trim()
|
|
166
|
+
if (!trimmed?.startsWith("data: ")) continue
|
|
167
|
+
const data = trimmed.slice(6)
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const chunk = JSON.parse(data)
|
|
171
|
+
const candidate = chunk.candidates?.[0]
|
|
172
|
+
|
|
173
|
+
// Handle usage metadata
|
|
174
|
+
if (chunk.usageMetadata) {
|
|
175
|
+
usage = {
|
|
176
|
+
in: chunk.usageMetadata.promptTokenCount || usage.in,
|
|
177
|
+
out: chunk.usageMetadata.candidatesTokenCount || usage.out,
|
|
178
|
+
}
|
|
179
|
+
es.push({ type: "usage", usage })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!candidate) continue
|
|
183
|
+
|
|
184
|
+
// Map finish reason
|
|
185
|
+
if (candidate.finishReason) {
|
|
186
|
+
const reason = candidate.finishReason
|
|
187
|
+
if (reason === "STOP") stop = "stop"
|
|
188
|
+
else if (reason === "MAX_TOKENS") stop = "length"
|
|
189
|
+
else if (reason === "SAFETY" || reason === "RECITATION" || reason === "OTHER")
|
|
190
|
+
stop = "error"
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const parts = candidate.content?.parts
|
|
194
|
+
if (parts) {
|
|
195
|
+
for (const part of parts) {
|
|
196
|
+
const sig = part.thought_signature || part.thoughtSignature
|
|
197
|
+
|
|
198
|
+
// Handle text and thinking deltas
|
|
199
|
+
if (part.text) {
|
|
200
|
+
if (part.thought === true || typeof part.thought === "string") {
|
|
201
|
+
const thoughtText = typeof part.thought === "string" ? part.thought : part.text
|
|
202
|
+
es.push({ type: "thinking_delta", text: thoughtText })
|
|
203
|
+
content.push({ type: "thinking", text: thoughtText, signature: sig })
|
|
204
|
+
} else {
|
|
205
|
+
es.push({ type: "text_delta", text: part.text })
|
|
206
|
+
content.push({ type: "text", text: part.text, signature: sig })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Handle function calls (can be snake_case or camelCase in some API versions)
|
|
211
|
+
const fc = part.functionCall || part.function_call
|
|
212
|
+
if (fc) {
|
|
213
|
+
const name = fc.name
|
|
214
|
+
const args = (fc.args as Record<string, unknown>) || {}
|
|
215
|
+
const id = `call_${Math.random().toString(36).slice(2, 9)}`
|
|
216
|
+
|
|
217
|
+
const toolCall: ContentPart = {
|
|
218
|
+
type: "tool_call",
|
|
219
|
+
id,
|
|
220
|
+
name,
|
|
221
|
+
args,
|
|
222
|
+
signature: sig,
|
|
223
|
+
}
|
|
224
|
+
content.push(toolCall)
|
|
225
|
+
es.push({ type: "tool_call", call: toolCall })
|
|
226
|
+
stop = "tool_use"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (_e) {
|
|
231
|
+
if (data.trim() !== "" && data.trim() !== "[DONE]") {
|
|
232
|
+
// skip noise
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
es.finish({ content, usage, stop })
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (opts.signal?.aborted) return
|
|
241
|
+
const errorMsg = `Gemini Network/Request Error: ${e instanceof Error ? e.message : String(e)}`
|
|
242
|
+
es.push({ type: "text_delta", text: errorMsg })
|
|
243
|
+
es.finish({
|
|
244
|
+
content: [{ type: "text", text: errorMsg }],
|
|
245
|
+
usage: { in: 0, out: 0 },
|
|
246
|
+
stop: "error",
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
})()
|
|
250
|
+
|
|
251
|
+
return es
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
register("gemini", streamGemini)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantResult,
|
|
3
|
+
ContentPart,
|
|
4
|
+
Msg,
|
|
5
|
+
StopReason,
|
|
6
|
+
StreamEvent,
|
|
7
|
+
StreamFn,
|
|
8
|
+
StreamOpts,
|
|
9
|
+
ToolDef,
|
|
10
|
+
Usage,
|
|
11
|
+
} from "../types.ts"
|
|
12
|
+
import { consolidate } from "../util.ts"
|
|
13
|
+
import { register } from "./registry.ts"
|
|
14
|
+
import { EventStream } from "./stream.ts"
|
|
15
|
+
|
|
16
|
+
function msgToOpenAI(msg: Msg): Record<string, unknown> {
|
|
17
|
+
if (msg.role === "user") {
|
|
18
|
+
return {
|
|
19
|
+
role: "user",
|
|
20
|
+
content:
|
|
21
|
+
typeof msg.content === "string"
|
|
22
|
+
? msg.content
|
|
23
|
+
: msg.content.map((c) => {
|
|
24
|
+
if (c.type === "text") return { type: "text", text: c.text }
|
|
25
|
+
if (c.type === "image")
|
|
26
|
+
return { type: "image_url", image_url: { url: `data:${c.mime};base64,${c.data}` } }
|
|
27
|
+
return { type: "text", text: "" }
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (msg.role === "assistant") {
|
|
32
|
+
const textParts: string[] = []
|
|
33
|
+
const toolCalls: unknown[] = []
|
|
34
|
+
|
|
35
|
+
for (const c of msg.content) {
|
|
36
|
+
if (c.type === "text") textParts.push(c.text)
|
|
37
|
+
// thinking parts are internal — never sent back to the API
|
|
38
|
+
if (c.type === "tool_call")
|
|
39
|
+
toolCalls.push({
|
|
40
|
+
type: "function",
|
|
41
|
+
id: c.id,
|
|
42
|
+
function: { name: c.name, arguments: JSON.stringify(c.args) },
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result: Record<string, unknown> = {
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content: textParts.length > 0 ? textParts.join("") : null,
|
|
49
|
+
}
|
|
50
|
+
if (toolCalls.length > 0) result.tool_calls = toolCalls
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
// tool_result
|
|
54
|
+
if (msg.role === "tool_result") {
|
|
55
|
+
return {
|
|
56
|
+
role: "tool",
|
|
57
|
+
tool_call_id: msg.callId,
|
|
58
|
+
content: msg.content.map((c) => (c.type === "text" ? c.text : JSON.stringify(c))).join("\n"),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { role: "user", content: "" }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toolsToOpenAI(tools: ToolDef[]): unknown[] {
|
|
65
|
+
return tools.map((t) => ({
|
|
66
|
+
type: "function",
|
|
67
|
+
function: {
|
|
68
|
+
name: t.name,
|
|
69
|
+
description: t.description,
|
|
70
|
+
parameters: t.parameters,
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const streamOpenAI: StreamFn = (
|
|
76
|
+
opts: StreamOpts,
|
|
77
|
+
): EventStream<StreamEvent, AssistantResult> => {
|
|
78
|
+
const es = new EventStream<StreamEvent, AssistantResult>()
|
|
79
|
+
|
|
80
|
+
;(async () => {
|
|
81
|
+
try {
|
|
82
|
+
const body = {
|
|
83
|
+
model: opts.model.id,
|
|
84
|
+
messages: [{ role: "system", content: opts.system }, ...opts.messages.map(msgToOpenAI)],
|
|
85
|
+
tools: opts.tools.length > 0 ? toolsToOpenAI(opts.tools) : undefined,
|
|
86
|
+
stream: true,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch(`${opts.baseUrl}/chat/completions`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
signal: opts.signal,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const text = await response.text()
|
|
101
|
+
const errorMsg = `API error ${response.status}: ${text}`
|
|
102
|
+
es.push({ type: "text_delta", text: errorMsg })
|
|
103
|
+
es.finish({
|
|
104
|
+
content: [{ type: "text", text: errorMsg }],
|
|
105
|
+
usage: { in: 0, out: 0 },
|
|
106
|
+
stop: "error",
|
|
107
|
+
})
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reader = response.body?.getReader()
|
|
112
|
+
if (!reader) {
|
|
113
|
+
es.finish({ content: [], usage: { in: 0, out: 0 }, stop: "error" })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const decoder = new TextDecoder()
|
|
118
|
+
let buffer = ""
|
|
119
|
+
const currentToolCalls = new Map<number, { id: string; name: string; args: string }>()
|
|
120
|
+
let usage: Usage = { in: 0, out: 0 }
|
|
121
|
+
let stop = "stop"
|
|
122
|
+
const textParts: ContentPart[] = []
|
|
123
|
+
|
|
124
|
+
while (true) {
|
|
125
|
+
const { done, value } = await reader.read()
|
|
126
|
+
if (done) break
|
|
127
|
+
|
|
128
|
+
buffer += decoder.decode(value, { stream: true })
|
|
129
|
+
const lines = buffer.split("\n")
|
|
130
|
+
buffer = lines.pop() ?? ""
|
|
131
|
+
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const trimmed = line.trim()
|
|
134
|
+
if (!trimmed?.startsWith("data: ")) continue
|
|
135
|
+
const data = trimmed.slice(6)
|
|
136
|
+
if (data === "[DONE]") continue
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const chunk = JSON.parse(data)
|
|
140
|
+
const delta = chunk.choices?.[0]?.delta
|
|
141
|
+
if (!delta) continue
|
|
142
|
+
|
|
143
|
+
if (delta.content) {
|
|
144
|
+
es.push({ type: "text_delta", text: delta.content })
|
|
145
|
+
textParts.push({ type: "text", text: delta.content })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (delta.tool_calls) {
|
|
149
|
+
for (const tc of delta.tool_calls) {
|
|
150
|
+
const idx = tc.index ?? 0
|
|
151
|
+
if (!currentToolCalls.has(idx)) {
|
|
152
|
+
currentToolCalls.set(idx, {
|
|
153
|
+
id: tc.id ?? "",
|
|
154
|
+
name: tc.function?.name ?? "",
|
|
155
|
+
args: "",
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const existing = currentToolCalls.get(idx)!
|
|
159
|
+
if (tc.id) existing.id = tc.id
|
|
160
|
+
if (tc.function?.name) existing.name = tc.function.name
|
|
161
|
+
if (tc.function?.arguments) existing.args += tc.function.arguments
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (chunk.usage) {
|
|
166
|
+
usage = {
|
|
167
|
+
in: chunk.usage.prompt_tokens ?? 0,
|
|
168
|
+
out: chunk.usage.completion_tokens ?? 0,
|
|
169
|
+
}
|
|
170
|
+
es.push({ type: "usage", usage })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const finishReason = chunk.choices?.[0]?.finish_reason
|
|
174
|
+
if (finishReason) stop = finishReason
|
|
175
|
+
} catch {
|
|
176
|
+
// Skip malformed JSON chunks
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const content: AssistantResult["content"] = consolidate([...textParts])
|
|
182
|
+
for (const [, tc] of currentToolCalls) {
|
|
183
|
+
content.push({
|
|
184
|
+
type: "tool_call",
|
|
185
|
+
id: tc.id,
|
|
186
|
+
name: tc.name,
|
|
187
|
+
args: JSON.parse(tc.args || "{}"),
|
|
188
|
+
})
|
|
189
|
+
es.push({
|
|
190
|
+
type: "tool_call",
|
|
191
|
+
call: {
|
|
192
|
+
type: "tool_call",
|
|
193
|
+
id: tc.id,
|
|
194
|
+
name: tc.name,
|
|
195
|
+
args: JSON.parse(tc.args || "{}"),
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
stop = "tool_use"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
es.finish({ content, usage, stop: stop as StopReason })
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (opts.signal?.aborted) return
|
|
204
|
+
const errorMsg = `Unexpected error: ${e instanceof Error ? e.message : String(e)}`
|
|
205
|
+
es.push({ type: "text_delta", text: errorMsg })
|
|
206
|
+
es.finish({
|
|
207
|
+
content: [{ type: "text", text: errorMsg }],
|
|
208
|
+
usage: { in: 0, out: 0 },
|
|
209
|
+
stop: "error",
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
})()
|
|
213
|
+
|
|
214
|
+
return es
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Auto-register
|
|
218
|
+
register("openai", streamOpenAI)
|