fix(stream): match Command Code CLI transport behavior
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
|||||||
getModelsTimeoutMs,
|
getModelsTimeoutMs,
|
||||||
inputModalitiesForModel,
|
inputModalitiesForModel,
|
||||||
loadCommandCodeModels,
|
loadCommandCodeModels,
|
||||||
|
MODEL_EFFORTS,
|
||||||
thinkingMetadataForModel,
|
thinkingMetadataForModel,
|
||||||
type CommandCodeModel,
|
type CommandCodeModel,
|
||||||
} from "./src/models.ts"
|
} from "./src/models.ts"
|
||||||
@@ -37,7 +38,7 @@ import { createCommandCodeRuntime } from "./src/runtime.ts"
|
|||||||
import { createCommandCodeTransportRouter } from "./src/transport.ts"
|
import { createCommandCodeTransportRouter } from "./src/transport.ts"
|
||||||
|
|
||||||
function commandCodeHeaders(): Record<string, string> | undefined {
|
function commandCodeHeaders(): Record<string, string> | undefined {
|
||||||
if (process.env.COMMANDCODE_ZDR === "1") {
|
if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") {
|
||||||
return { "x-cmd-zdr": "1" }
|
return { "x-cmd-zdr": "1" }
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
@@ -52,7 +53,7 @@ function createProviderConfig(
|
|||||||
return {
|
return {
|
||||||
name: "Command Code",
|
name: "Command Code",
|
||||||
baseUrl: apiBase,
|
baseUrl: apiBase,
|
||||||
apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY",
|
apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY",
|
||||||
api: "commandcode-custom",
|
api: "commandcode-custom",
|
||||||
streamSimple: streamCommandCode,
|
streamSimple: streamCommandCode,
|
||||||
headers,
|
headers,
|
||||||
@@ -79,7 +80,7 @@ function createProviderConfig(
|
|||||||
? {
|
? {
|
||||||
supportsStore: false,
|
supportsStore: false,
|
||||||
supportsDeveloperRole: false,
|
supportsDeveloperRole: false,
|
||||||
supportsReasoningEffort: true,
|
supportsReasoningEffort: MODEL_EFFORTS[model.id] !== undefined,
|
||||||
maxTokensField: "max_tokens",
|
maxTokensField: "max_tokens",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface AuthServer {
|
|||||||
export interface AuthServerOptions {
|
export interface AuthServerOptions {
|
||||||
startPort?: number
|
startPort?: number
|
||||||
portRange?: number
|
portRange?: number
|
||||||
|
expectedState?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function listenOnAvailablePort(
|
function listenOnAvailablePort(
|
||||||
@@ -181,6 +182,12 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise<
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.expectedState !== undefined && state !== options.expectedState) {
|
||||||
|
res.writeHead(403)
|
||||||
|
res.end(JSON.stringify({ success: false, error: "Invalid state token" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(200)
|
res.writeHead(200)
|
||||||
res.end(JSON.stringify({ success: true }))
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
|
||||||
|
|||||||
+42
-11
@@ -107,6 +107,7 @@ export function getApiKey(
|
|||||||
} = {},
|
} = {},
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const env = options.env ?? process.env
|
const env = options.env ?? process.env
|
||||||
|
if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY
|
||||||
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
|
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
|
||||||
|
|
||||||
const home = options.homeDir?.() ?? homedir()
|
const home = options.homeDir?.() ?? homedir()
|
||||||
@@ -138,10 +139,11 @@ export function getApiKey(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hosts such as OMP may pass the literal env-var name "$COMMANDCODE_API_KEY"
|
// Hosts such as OMP may pass a literal env-var name as the "resolved" registry
|
||||||
// (or "COMMANDCODE_API_KEY") as the "resolved" registry key instead of the
|
// key instead of the actual credential. Treat those as unresolved.
|
||||||
// actual credential. Treat those as unresolved.
|
|
||||||
export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([
|
export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([
|
||||||
|
"$COMMAND_CODE_API_KEY",
|
||||||
|
"COMMAND_CODE_API_KEY",
|
||||||
"$COMMANDCODE_API_KEY",
|
"$COMMANDCODE_API_KEY",
|
||||||
"COMMANDCODE_API_KEY",
|
"COMMANDCODE_API_KEY",
|
||||||
])
|
])
|
||||||
@@ -162,6 +164,16 @@ export function pickCommandCodeApiKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function textContent(message: { content?: unknown }): string {
|
export function textContent(message: { content?: unknown }): string {
|
||||||
|
if (typeof message.content === "string") return message.content
|
||||||
|
if (message.content === null || message.content === undefined) return ""
|
||||||
|
if (!Array.isArray(message.content)) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(message.content) ?? String(message.content)
|
||||||
|
} catch {
|
||||||
|
return String(message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return recordArray(message.content)
|
return recordArray(message.content)
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part) => stringValue(part.text) ?? "")
|
.map((part) => stringValue(part.text) ?? "")
|
||||||
@@ -182,7 +194,12 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
interface ToolCallState {
|
||||||
|
callIds: ReadonlySet<string>
|
||||||
|
resultIds: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallState(messages?: readonly MessageLike[]): ToolCallState {
|
||||||
const callIds = new Set<string>()
|
const callIds = new Set<string>()
|
||||||
const resultIds = new Set<string>()
|
const resultIds = new Set<string>()
|
||||||
|
|
||||||
@@ -194,12 +211,12 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
|||||||
if (id) callIds.add(id)
|
if (id) callIds.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (message.role === "toolResult") {
|
} else if (message.role === "toolResult" && message.toolCallId) {
|
||||||
if (message.toolCallId) resultIds.add(message.toolCallId)
|
resultIds.add(message.toolCallId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Set([...callIds].filter((id) => resultIds.has(id)))
|
return { callIds, resultIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messagesToCC(
|
export function messagesToCC(
|
||||||
@@ -210,7 +227,7 @@ export function messagesToCC(
|
|||||||
if (!allowImages) assertTextOnlyMessages(messages)
|
if (!allowImages) assertTextOnlyMessages(messages)
|
||||||
|
|
||||||
const out: unknown[] = []
|
const out: unknown[] = []
|
||||||
const pairedToolCallIds = completeToolCallIds(messages)
|
const { callIds, resultIds } = toolCallState(messages)
|
||||||
|
|
||||||
for (const message of messages ?? []) {
|
for (const message of messages ?? []) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
@@ -220,23 +237,37 @@ export function messagesToCC(
|
|||||||
})
|
})
|
||||||
} else if (message.role === "assistant") {
|
} else if (message.role === "assistant") {
|
||||||
const parts: unknown[] = []
|
const parts: unknown[] = []
|
||||||
|
const missingResults: unknown[] = []
|
||||||
for (const content of recordArray(message.content)) {
|
for (const content of recordArray(message.content)) {
|
||||||
if (content.type === "text") {
|
if (content.type === "text") {
|
||||||
parts.push({ type: "text", text: stringValue(content.text) ?? "" })
|
parts.push({ type: "text", text: stringValue(content.text) ?? "" })
|
||||||
} else if (content.type === "toolCall") {
|
} else if (content.type === "toolCall") {
|
||||||
const toolCallId = stringValue(content.id) ?? ""
|
const toolCallId = stringValue(content.id) ?? ""
|
||||||
if (!pairedToolCallIds.has(toolCallId)) continue
|
const toolName = stringValue(content.name) ?? ""
|
||||||
|
if (!toolCallId) continue
|
||||||
parts.push({
|
parts.push({
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: stringValue(content.name) ?? "",
|
toolName,
|
||||||
input: recordOrEmpty(content.arguments),
|
input: recordOrEmpty(content.arguments),
|
||||||
})
|
})
|
||||||
|
if (!resultIds.has(toolCallId)) {
|
||||||
|
missingResults.push({
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
output: {
|
||||||
|
type: "error-text",
|
||||||
|
value: "No result — the tool call did not complete (interrupted or lost).",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parts.length > 0) out.push({ role: "assistant", content: parts })
|
if (parts.length > 0) out.push({ role: "assistant", content: parts })
|
||||||
|
if (missingResults.length > 0) out.push({ role: "tool", content: missingResults })
|
||||||
} else if (message.role === "toolResult") {
|
} else if (message.role === "toolResult") {
|
||||||
if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue
|
if (!message.toolCallId || !callIds.has(message.toolCallId)) continue
|
||||||
out.push({
|
out.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
+55
-16
@@ -148,6 +148,10 @@ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): strin
|
|||||||
return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
|
return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUuid(value: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
export function projectSlugFromPath(pathName: string): string {
|
export function projectSlugFromPath(pathName: string): string {
|
||||||
const slug = pathName
|
const slug = pathName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -232,17 +236,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
const stream = deps.createStream()
|
const stream = deps.createStream()
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
// OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi)
|
// Some hosts pass a literal env-var reference instead of resolving it.
|
||||||
// or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of
|
const PLACEHOLDER_API_KEYS = new Set([
|
||||||
// resolving it. Filter out these specific strings.
|
"$COMMAND_CODE_API_KEY",
|
||||||
const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY"
|
"COMMAND_CODE_API_KEY",
|
||||||
const OLD_API_KEY_REF = "COMMANDCODE_API_KEY"
|
"$COMMANDCODE_API_KEY",
|
||||||
|
"COMMANDCODE_API_KEY",
|
||||||
|
])
|
||||||
const hostKey =
|
const hostKey =
|
||||||
options?.apiKey &&
|
options?.apiKey && !PLACEHOLDER_API_KEYS.has(options.apiKey) ? options.apiKey : undefined
|
||||||
options.apiKey !== LEGACY_API_KEY_REF &&
|
|
||||||
options.apiKey !== OLD_API_KEY_REF
|
|
||||||
? options.apiKey
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const apiKey =
|
const apiKey =
|
||||||
hostKey ??
|
hostKey ??
|
||||||
@@ -262,7 +264,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
usage: defaultUsage(),
|
usage: defaultUsage(),
|
||||||
stopReason: "error",
|
stopReason: "error",
|
||||||
errorMessage:
|
errorMessage:
|
||||||
"No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
|
"No Command Code API key. Run /login and select Command Code, set COMMAND_CODE_API_KEY (or legacy COMMANDCODE_API_KEY), or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
|
||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
}
|
}
|
||||||
stream.push({ type: "error", reason: "error", error: msg })
|
stream.push({ type: "error", reason: "error", error: msg })
|
||||||
@@ -424,6 +426,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "finish": {
|
case "finish": {
|
||||||
|
const rawFinishReason = stringValue(event.rawFinishReason)
|
||||||
|
if (
|
||||||
|
rawFinishReason &&
|
||||||
|
/^(?:network|connection|upstream)[-_\s]?error$/i.test(rawFinishReason)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider finished with reason "${rawFinishReason}" — upstream connection failed mid-stream`,
|
||||||
|
)
|
||||||
|
}
|
||||||
const usage = commandCodeUsage(event)
|
const usage = commandCodeUsage(event)
|
||||||
if (usage) {
|
if (usage) {
|
||||||
const details = commandCodeInputTokenDetails(usage)
|
const details = commandCodeInputTokenDetails(usage)
|
||||||
@@ -447,6 +458,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "abort": {
|
||||||
|
throw abortError("Request aborted")
|
||||||
|
}
|
||||||
|
|
||||||
case "error": {
|
case "error": {
|
||||||
const message =
|
const message =
|
||||||
commandCodeErrorMessage(event.error) ??
|
commandCodeErrorMessage(event.error) ??
|
||||||
@@ -464,7 +479,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
if (controller.signal.aborted) throw abortError("Aborted")
|
if (controller.signal.aborted) throw abortError("Aborted")
|
||||||
|
|
||||||
const workingDir = cwd()
|
const workingDir = cwd()
|
||||||
const threadId = uuid()
|
const threadId = options?.sessionId
|
||||||
|
? isUuid(options.sessionId)
|
||||||
|
? options.sessionId
|
||||||
|
: undefined
|
||||||
|
: uuid()
|
||||||
const reasoningEffort = mappedReasoningEffort(model, options)
|
const reasoningEffort = mappedReasoningEffort(model, options)
|
||||||
const timeoutMs = options?.timeoutMs
|
const timeoutMs = options?.timeoutMs
|
||||||
|
|
||||||
@@ -492,8 +511,8 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
tools: toolsToJson(context.tools),
|
tools: toolsToJson(context.tools),
|
||||||
system: systemPromptToText(context.systemPrompt),
|
system: systemPromptToText(context.systemPrompt),
|
||||||
max_tokens: generateMaxTokens(model, options),
|
max_tokens: generateMaxTokens(model, options),
|
||||||
temperature: 0.3,
|
|
||||||
stream: true,
|
stream: true,
|
||||||
|
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
||||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||||
},
|
},
|
||||||
threadId,
|
threadId,
|
||||||
@@ -523,7 +542,8 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
"x-cli-environment": "production",
|
"x-cli-environment": "production",
|
||||||
"x-project-slug": projectSlugFromPath(workingDir),
|
"x-project-slug": projectSlugFromPath(workingDir),
|
||||||
"x-taste-learning": "true",
|
"x-taste-learning": "true",
|
||||||
"x-co-flag": "false",
|
...(options?.sessionId ? { "x-session-id": options.sessionId } : {}),
|
||||||
|
"User-Agent": "cli",
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
}
|
}
|
||||||
const bodyStr = JSON.stringify(body)
|
const bodyStr = JSON.stringify(body)
|
||||||
@@ -636,6 +656,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
const { done, value } = await raceAbort(reader.read(), attemptController.signal)
|
const { done, value } = await raceAbort(reader.read(), attemptController.signal)
|
||||||
if (done) {
|
if (done) {
|
||||||
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
|
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
|
||||||
|
if (!finished) {
|
||||||
|
throw new Error(
|
||||||
|
"Stream ended unexpectedly before completion (no finish event) — response was truncated",
|
||||||
|
)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (controller.signal.aborted) throw abortError("Aborted")
|
if (controller.signal.aborted) throw abortError("Aborted")
|
||||||
@@ -659,7 +684,12 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
reader = undefined
|
reader = undefined
|
||||||
|
|
||||||
if (controller.signal.aborted) throw streamError
|
if (
|
||||||
|
controller.signal.aborted ||
|
||||||
|
(streamError instanceof Error && streamError.name === "AbortError")
|
||||||
|
) {
|
||||||
|
throw streamError
|
||||||
|
}
|
||||||
|
|
||||||
// Never retry after visible content was emitted (including timeout mid-stream).
|
// Never retry after visible content was emitted (including timeout mid-stream).
|
||||||
const canRetry = output.content.length === 0 && attempt < maxRetries
|
const canRetry = output.content.length === 0 && attempt < maxRetries
|
||||||
@@ -679,6 +709,12 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
throw streamError
|
throw streamError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!finished) {
|
||||||
|
throw new Error(
|
||||||
|
"Stream ended unexpectedly before completion (no finish event) — response was truncated",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Stream completed successfully.
|
// Stream completed successfully.
|
||||||
endTextBlock()
|
endTextBlock()
|
||||||
endThinking()
|
endThinking()
|
||||||
@@ -696,7 +732,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
|
const reason: ErrorReason =
|
||||||
|
controller.signal.aborted || (error instanceof Error && error.name === "AbortError")
|
||||||
|
? "aborted"
|
||||||
|
: "error"
|
||||||
output.stopReason = reason
|
output.stopReason = reason
|
||||||
output.errorMessage =
|
output.errorMessage =
|
||||||
reason === "aborted"
|
reason === "aborted"
|
||||||
|
|||||||
+33
-10
@@ -18,7 +18,8 @@ import { startAuthServer } from "./auth-server.ts"
|
|||||||
|
|
||||||
const STUDIO_BASE_URL = "https://commandcode.ai"
|
const STUDIO_BASE_URL = "https://commandcode.ai"
|
||||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
|
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
|
||||||
const DEFAULT_AUTH_TIMEOUT_MS = 15_000
|
const DEFAULT_AUTH_TIMEOUT_MS = 120_000
|
||||||
|
const DEFAULT_API_BASE = "https://api.commandcode.ai"
|
||||||
|
|
||||||
export interface OAuthLoginCallbacks {
|
export interface OAuthLoginCallbacks {
|
||||||
onAuth(params: { url: string }): void
|
onAuth(params: { url: string }): void
|
||||||
@@ -95,9 +96,34 @@ export function sanitizeApiKey(input: string): string {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function validateApiKey(
|
||||||
|
apiKey: string,
|
||||||
|
options: { fetchImpl?: typeof fetch; apiBase?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await (options.fetchImpl ?? fetch)(
|
||||||
|
`${options.apiBase ?? DEFAULT_API_BASE}/alpha/whoami`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not validate the Command Code API key: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Invalid Command Code API key")
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Could not validate the Command Code API key (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) {
|
async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) {
|
||||||
const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message }))
|
const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message }))
|
||||||
if (!apiKey) throw new Error("No Command Code API key provided")
|
if (!apiKey) throw new Error("No Command Code API key provided")
|
||||||
|
await validateApiKey(apiKey)
|
||||||
return credentialsFromApiKey(apiKey)
|
return credentialsFromApiKey(apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +156,10 @@ async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise<LoginCho
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||||
|
const stateToken = generateStateToken()
|
||||||
let authServer
|
let authServer
|
||||||
try {
|
try {
|
||||||
authServer = await startAuthServer()
|
authServer = await startAuthServer({ expectedState: stateToken })
|
||||||
} catch {
|
} catch {
|
||||||
return promptForApiKey(
|
return promptForApiKey(
|
||||||
callbacks,
|
callbacks,
|
||||||
@@ -140,7 +167,6 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateToken = generateStateToken()
|
|
||||||
const callbackUrl = `http://localhost:${authServer.port}/callback`
|
const callbackUrl = `http://localhost:${authServer.port}/callback`
|
||||||
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}`
|
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}`
|
||||||
|
|
||||||
@@ -164,12 +190,6 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate state token to prevent CSRF.
|
|
||||||
if (callback.state !== stateToken) {
|
|
||||||
authServer.server.close()
|
|
||||||
throw new Error("State token mismatch. Authentication may have been tampered with.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return credentialsFromApiKey(callback.apiKey)
|
return credentialsFromApiKey(callback.apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +202,10 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
|
|||||||
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||||
const choice = await chooseLoginFlow(callbacks)
|
const choice = await chooseLoginFlow(callbacks)
|
||||||
|
|
||||||
if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey)
|
if (choice.type === "apiKey") {
|
||||||
|
await validateApiKey(choice.apiKey)
|
||||||
|
return credentialsFromApiKey(choice.apiKey)
|
||||||
|
}
|
||||||
if (choice.type === "prompt") {
|
if (choice.type === "prompt") {
|
||||||
return promptForApiKey(callbacks, "Paste your Command Code API key:")
|
return promptForApiKey(callbacks, "Paste your Command Code API key:")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface StreamOptions {
|
|||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
maxTokens?: number
|
maxTokens?: number
|
||||||
|
temperature?: number
|
||||||
|
sessionId?: string
|
||||||
/** Resolved pi thinking level; forwarded only through the model's map. */
|
/** Resolved pi thinking level; forwarded only through the model's map. */
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>
|
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>
|
||||||
|
|||||||
+99
-19
@@ -9,7 +9,7 @@ import assert from "node:assert/strict"
|
|||||||
import { describe, it } from "node:test"
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
import { startAuthServer, type AuthCallback } from "../src/auth-server.ts"
|
import { startAuthServer, type AuthCallback } from "../src/auth-server.ts"
|
||||||
import { getApiKey, login, refreshToken, sanitizeApiKey } from "../src/oauth.ts"
|
import { getApiKey, login, refreshToken, sanitizeApiKey, validateApiKey } from "../src/oauth.ts"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: wait for an HTTP server to close, or resolve immediately if already closed.
|
* Helper: wait for an HTTP server to close, or resolve immediately if already closed.
|
||||||
@@ -24,9 +24,27 @@ function waitForClose(server: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withValidApiKeyFetch<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
globalThis.fetch = (input, init) => {
|
||||||
|
if (String(input).endsWith("/alpha/whoami")) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 }))
|
||||||
|
}
|
||||||
|
return originalFetch(input, init)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await run()
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("startAuthServer()", () => {
|
describe("startAuthServer()", () => {
|
||||||
it("starts on a localhost port and accepts a valid callback POST", async () => {
|
it("starts on a localhost port and accepts a valid callback POST", async () => {
|
||||||
const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 })
|
const { server, port, waitForCallback } = await startAuthServer({
|
||||||
|
startPort: 0,
|
||||||
|
expectedState: "test-state-token",
|
||||||
|
})
|
||||||
|
|
||||||
const callbackData: AuthCallback = {
|
const callbackData: AuthCallback = {
|
||||||
apiKey: "user_testKey123",
|
apiKey: "user_testKey123",
|
||||||
@@ -57,6 +75,42 @@ describe("startAuthServer()", () => {
|
|||||||
await waitForClose(server)
|
await waitForClose(server)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("rejects a mismatched state without closing the callback server", async () => {
|
||||||
|
const { server, port, waitForCallback } = await startAuthServer({
|
||||||
|
startPort: 0,
|
||||||
|
expectedState: "correct-state",
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidResponse = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey: "user_badState",
|
||||||
|
state: "wrong-state",
|
||||||
|
userId: "user_789",
|
||||||
|
userName: "Attacker",
|
||||||
|
keyName: "evil-key",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
assert.equal(invalidResponse.status, 403)
|
||||||
|
assert.equal(server.listening, true)
|
||||||
|
|
||||||
|
const validResponse = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey: "user_valid",
|
||||||
|
state: "correct-state",
|
||||||
|
userId: "user_123",
|
||||||
|
userName: "Valid User",
|
||||||
|
keyName: "valid-key",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
assert.equal(validResponse.status, 200)
|
||||||
|
assert.equal((await waitForCallback).apiKey, "user_valid")
|
||||||
|
await waitForClose(server)
|
||||||
|
})
|
||||||
|
|
||||||
it("rejects when the callback indicates access_denied", async () => {
|
it("rejects when the callback indicates access_denied", async () => {
|
||||||
const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 })
|
const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 })
|
||||||
|
|
||||||
@@ -176,6 +230,18 @@ describe("OAuth functions", () => {
|
|||||||
it("sanitizeApiKey removes paste markers, control chars, and whitespace", () => {
|
it("sanitizeApiKey removes paste markers, control chars, and whitespace", () => {
|
||||||
assert.equal(sanitizeApiKey("\u001b[200~ user_manualKey\n\u001b[201~"), "user_manualKey")
|
assert.equal(sanitizeApiKey("\u001b[200~ user_manualKey\n\u001b[201~"), "user_manualKey")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("validates manual API keys through whoami", async () => {
|
||||||
|
await validateApiKey("valid-key", {
|
||||||
|
fetchImpl: () => Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 })),
|
||||||
|
})
|
||||||
|
await assert.rejects(
|
||||||
|
validateApiKey("invalid-key", {
|
||||||
|
fetchImpl: () => Promise.resolve(new Response("unauthorized", { status: 401 })),
|
||||||
|
}),
|
||||||
|
/Invalid Command Code API key/,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("login()", () => {
|
describe("login()", () => {
|
||||||
@@ -242,7 +308,8 @@ describe("login()", () => {
|
|||||||
const promptMessages: string[] = []
|
const promptMessages: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await login({
|
const result = await withValidApiKeyFetch(() =>
|
||||||
|
login({
|
||||||
onAuth(params: { url: string }) {
|
onAuth(params: { url: string }) {
|
||||||
authUrl = params.url
|
authUrl = params.url
|
||||||
},
|
},
|
||||||
@@ -250,7 +317,8 @@ describe("login()", () => {
|
|||||||
promptMessages.push(params.message)
|
promptMessages.push(params.message)
|
||||||
return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~"
|
return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~"
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/)
|
assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/)
|
||||||
assert.match(promptMessages[1] ?? "", /Paste your Command Code API key/)
|
assert.match(promptMessages[1] ?? "", /Paste your Command Code API key/)
|
||||||
@@ -265,14 +333,16 @@ describe("login()", () => {
|
|||||||
|
|
||||||
it("accepts a directly pasted API key", async () => {
|
it("accepts a directly pasted API key", async () => {
|
||||||
let authOpened = false
|
let authOpened = false
|
||||||
const result = await login({
|
const result = await withValidApiKeyFetch(() =>
|
||||||
|
login({
|
||||||
onAuth() {
|
onAuth() {
|
||||||
authOpened = true
|
authOpened = true
|
||||||
},
|
},
|
||||||
onPrompt(): Promise<string> {
|
onPrompt(): Promise<string> {
|
||||||
return Promise.resolve("user_directApiKey")
|
return Promise.resolve("user_directApiKey")
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
assert.equal(authOpened, false)
|
assert.equal(authOpened, false)
|
||||||
assert.equal(result.access, "user_directApiKey")
|
assert.equal(result.access, "user_directApiKey")
|
||||||
@@ -280,7 +350,8 @@ describe("login()", () => {
|
|||||||
|
|
||||||
it("offers an explicit API key prompt", async () => {
|
it("offers an explicit API key prompt", async () => {
|
||||||
let promptCount = 0
|
let promptCount = 0
|
||||||
const result = await login({
|
const result = await withValidApiKeyFetch(() =>
|
||||||
|
login({
|
||||||
onAuth() {
|
onAuth() {
|
||||||
throw new Error("browser should not open")
|
throw new Error("browser should not open")
|
||||||
},
|
},
|
||||||
@@ -288,13 +359,14 @@ describe("login()", () => {
|
|||||||
promptCount += 1
|
promptCount += 1
|
||||||
return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey")
|
return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey")
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
assert.equal(result.access, "user_promptedApiKey")
|
assert.equal(result.access, "user_promptedApiKey")
|
||||||
assert.equal(promptCount, 2)
|
assert.equal(promptCount, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rejects on state token mismatch", async () => {
|
it("keeps waiting after a state mismatch and accepts the legitimate callback", async () => {
|
||||||
let authUrl = ""
|
let authUrl = ""
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
onAuth(params: { url: string }) {
|
onAuth(params: { url: string }) {
|
||||||
@@ -305,12 +377,7 @@ describe("login()", () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginPromise: Promise<string> = login(callbacks).then(
|
const loginPromise = login(callbacks)
|
||||||
() => {
|
|
||||||
throw new Error("Expected login to reject")
|
|
||||||
},
|
|
||||||
(e: Error) => e.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Wait for onAuth to be called asynchronously
|
// Wait for onAuth to be called asynchronously
|
||||||
while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10))
|
while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
@@ -318,8 +385,8 @@ describe("login()", () => {
|
|||||||
const url = new URL(authUrl)
|
const url = new URL(authUrl)
|
||||||
const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0")
|
const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0")
|
||||||
|
|
||||||
// Post back with a wrong state token
|
// Post back with a wrong state token.
|
||||||
await fetch(`http://127.0.0.1:${port}/callback`, {
|
const invalidResponse = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" },
|
headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -331,7 +398,20 @@ describe("login()", () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const errorMsg = await loginPromise
|
assert.equal(invalidResponse.status, 403)
|
||||||
assert.match(errorMsg, /State token mismatch/)
|
|
||||||
|
const validResponse = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey: "user_goodState",
|
||||||
|
state: url.searchParams.get("state"),
|
||||||
|
userId: "user_123",
|
||||||
|
userName: "Real User",
|
||||||
|
keyName: "real-key",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
assert.equal(validResponse.status, 200)
|
||||||
|
assert.equal((await loginPromise).access, "user_goodState")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,8 +27,18 @@ import { redactCommandCodeErrorText } from "../src/overflow.ts"
|
|||||||
import { objectAt } from "./helpers.ts"
|
import { objectAt } from "./helpers.ts"
|
||||||
|
|
||||||
describe("getApiKey()", () => {
|
describe("getApiKey()", () => {
|
||||||
it("uses COMMANDCODE_API_KEY from provided env", () => {
|
it("uses the official API key env var before the legacy alias", () => {
|
||||||
assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key")
|
assert.equal(
|
||||||
|
getApiKey({
|
||||||
|
env: { COMMAND_CODE_API_KEY: "official-key", COMMANDCODE_API_KEY: "legacy-key" },
|
||||||
|
authPaths: [],
|
||||||
|
}),
|
||||||
|
"official-key",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
getApiKey({ env: { COMMANDCODE_API_KEY: "legacy-key" }, authPaths: [] }),
|
||||||
|
"legacy-key",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => {
|
it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => {
|
||||||
@@ -109,11 +119,14 @@ describe("error redaction", () => {
|
|||||||
|
|
||||||
describe("pickCommandCodeApiKey()", () => {
|
describe("pickCommandCodeApiKey()", () => {
|
||||||
it("falls back to the host key for a placeholder registry value", () => {
|
it("falls back to the host key for a placeholder registry value", () => {
|
||||||
|
assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", "file-key"), "file-key")
|
||||||
|
assert.equal(pickCommandCodeApiKey("COMMAND_CODE_API_KEY", "file-key"), "file-key")
|
||||||
assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", "file-key"), "file-key")
|
assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", "file-key"), "file-key")
|
||||||
assert.equal(pickCommandCodeApiKey("COMMANDCODE_API_KEY", "file-key"), "file-key")
|
assert.equal(pickCommandCodeApiKey("COMMANDCODE_API_KEY", "file-key"), "file-key")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns undefined when only a placeholder is provided (no fallback)", () => {
|
it("returns undefined when only a placeholder is provided (no fallback)", () => {
|
||||||
|
assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", undefined), undefined)
|
||||||
assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", undefined), undefined)
|
assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", undefined), undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -199,6 +212,12 @@ describe("textContent()", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("normalizes malformed string and object content", () => {
|
||||||
|
assert.equal(textContent({ content: "raw result" }), "raw result")
|
||||||
|
assert.equal(textContent({ content: { ok: true } }), '{"ok":true}')
|
||||||
|
assert.equal(textContent({ content: null }), "")
|
||||||
|
})
|
||||||
|
|
||||||
it("handles empty or missing content", () => {
|
it("handles empty or missing content", () => {
|
||||||
assert.equal(textContent({ content: [] }), "")
|
assert.equal(textContent({ content: [] }), "")
|
||||||
assert.equal(textContent({}), "")
|
assert.equal(textContent({}), "")
|
||||||
@@ -531,6 +550,23 @@ describe("messagesToCC()", () => {
|
|||||||
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld")
|
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("preserves malformed string tool results instead of sending empty output", () => {
|
||||||
|
const result = messagesToCC([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "c1",
|
||||||
|
toolName: "read",
|
||||||
|
content: "raw result",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(objectAt(result, ["1", "content", "0", "output", "value"]), "raw result")
|
||||||
|
})
|
||||||
|
|
||||||
it("serializes image inputs in the current Command Code wire format", () => {
|
it("serializes image inputs in the current Command Code wire format", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
messagesToCC(
|
messagesToCC(
|
||||||
@@ -632,7 +668,7 @@ describe("messagesToCC()", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("drops orphaned tool calls that have no matching tool result", () => {
|
it("synthesizes missing results for orphaned tool calls", () => {
|
||||||
const result = messagesToCC([
|
const result = messagesToCC([
|
||||||
{ role: "user", content: "edit a file" },
|
{ role: "user", content: "edit a file" },
|
||||||
{
|
{
|
||||||
@@ -651,7 +687,12 @@ describe("messagesToCC()", () => {
|
|||||||
|
|
||||||
assert.equal(objectAt(result, ["1", "role"]), "assistant")
|
assert.equal(objectAt(result, ["1", "role"]), "assistant")
|
||||||
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text")
|
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text")
|
||||||
assert.equal(objectAt(result, ["1", "content", "1"]), undefined)
|
assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call")
|
||||||
|
assert.equal(objectAt(result, ["2", "role"]), "tool")
|
||||||
|
assert.match(
|
||||||
|
String(objectAt(result, ["2", "content", "0", "output", "value"])),
|
||||||
|
/did not complete/,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("handles empty conversations", () => {
|
it("handles empty conversations", () => {
|
||||||
|
|||||||
+119
-2
@@ -77,6 +77,23 @@ describe("streamCommandCode — auth", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("accepts the official CLI API key environment variable", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
|
})
|
||||||
|
const { streamCommandCode } = createTestDeps({
|
||||||
|
apiBase: server.baseUrl(),
|
||||||
|
env: { COMMAND_CODE_API_KEY: "official-env-key" },
|
||||||
|
})
|
||||||
|
|
||||||
|
await collectEvents(
|
||||||
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "$COMMAND_CODE_API_KEY" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(server.lastRequestHeaders().authorization, "Bearer official-env-key")
|
||||||
|
})
|
||||||
|
|
||||||
it("uses options.apiKey in the Authorization header", async () => {
|
it("uses options.apiKey in the Authorization header", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -555,7 +572,7 @@ describe("streamCommandCode — request serialization", () => {
|
|||||||
assert.equal(objectAt(body, ["params", "stream"]), true)
|
assert.equal(objectAt(body, ["params", "stream"]), true)
|
||||||
assert.equal(objectAt(body, ["params", "max_tokens"]), 500)
|
assert.equal(objectAt(body, ["params", "max_tokens"]), 500)
|
||||||
assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined)
|
assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined)
|
||||||
assert.equal(objectAt(body, ["params", "temperature"]), 0.3)
|
assert.equal(objectAt(body, ["params", "temperature"]), undefined)
|
||||||
assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.")
|
assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.")
|
||||||
assert.equal(objectAt(body, ["memory"]), null)
|
assert.equal(objectAt(body, ["memory"]), null)
|
||||||
assert.equal(objectAt(body, ["taste"]), null)
|
assert.equal(objectAt(body, ["taste"]), null)
|
||||||
@@ -573,10 +590,53 @@ describe("streamCommandCode — request serialization", () => {
|
|||||||
assert.equal(headers["x-command-code-version"], COMMAND_CODE_CLI_VERSION)
|
assert.equal(headers["x-command-code-version"], COMMAND_CODE_CLI_VERSION)
|
||||||
assert.equal(headers["x-project-slug"], "repo")
|
assert.equal(headers["x-project-slug"], "repo")
|
||||||
assert.equal(headers["x-taste-learning"], "true")
|
assert.equal(headers["x-taste-learning"], "true")
|
||||||
assert.equal(headers["x-co-flag"], "false")
|
assert.equal(headers["user-agent"], "cli")
|
||||||
|
assert.equal(headers["x-co-flag"], undefined)
|
||||||
assert.equal(headers["x-session-id"], undefined)
|
assert.equal(headers["x-session-id"], undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("forwards explicit temperature and stable session metadata", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
|
})
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
|
await collectEvents(
|
||||||
|
streamCommandCode(makeModel(), makeContext(), {
|
||||||
|
apiKey: "mock-key",
|
||||||
|
temperature: 0.7,
|
||||||
|
sessionId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = server.lastRequestBody()
|
||||||
|
assert.equal(objectAt(body, ["params", "temperature"]), 0.7)
|
||||||
|
assert.equal(objectAt(body, ["threadId"]), "11111111-1111-4111-8111-111111111111")
|
||||||
|
assert.equal(
|
||||||
|
server.lastRequestHeaders()["x-session-id"],
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("omits non-UUID session ids from the generate thread id", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
|
})
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
|
await collectEvents(
|
||||||
|
streamCommandCode(makeModel(), makeContext(), {
|
||||||
|
apiKey: "mock-key",
|
||||||
|
sessionId: "human-readable-session",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(objectAt(server.lastRequestBody(), ["threadId"]), undefined)
|
||||||
|
assert.equal(server.lastRequestHeaders()["x-session-id"], "human-readable-session")
|
||||||
|
})
|
||||||
|
|
||||||
it("accepts the legacy OMP nested reasoning map", async () => {
|
it("accepts the legacy OMP nested reasoning map", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -819,6 +879,63 @@ describe("streamCommandCode — upstream errors and malformed streams", () => {
|
|||||||
assert.equal(error.error.errorMessage, "provider failed")
|
assert.equal(error.error.errorMessage, "provider failed")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("rejects a truncated stream without a finish event", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [JSON.stringify({ type: "text-delta", text: "truncated" })],
|
||||||
|
})
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = events.at(-1)
|
||||||
|
assert.equal(error?.type, "error")
|
||||||
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
|
assert.match(error.error.errorMessage ?? "", /no finish event/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("maps an upstream abort event to an aborted request", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [JSON.stringify({ type: "abort" })],
|
||||||
|
})
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = events.at(-1)
|
||||||
|
assert.equal(error?.type, "error")
|
||||||
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
|
assert.equal(error.reason, "aborted")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects terminal upstream network failure reasons", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "upstream_error",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = events.at(-1)
|
||||||
|
assert.equal(error?.type, "error")
|
||||||
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
|
assert.match(error.error.errorMessage ?? "", /upstream connection failed/i)
|
||||||
|
})
|
||||||
|
|
||||||
it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => {
|
it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => {
|
||||||
const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n`
|
const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n`
|
||||||
const finishEvent = JSON.stringify({
|
const finishEvent = JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user