diff --git a/.changeset/quiet-balance-errors.md b/.changeset/quiet-balance-errors.md new file mode 100644 index 000000000000..683d4cb6844a --- /dev/null +++ b/.changeset/quiet-balance-errors.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +--- + +Keep balance lookup errors in the CLI log instead of printing them over the terminal interface. diff --git a/packages/kilo-gateway/src/api/profile.ts b/packages/kilo-gateway/src/api/profile.ts index 87caa463598e..f15561abb92f 100644 --- a/packages/kilo-gateway/src/api/profile.ts +++ b/packages/kilo-gateway/src/api/profile.ts @@ -64,7 +64,11 @@ export function defaultOrganizationId(profile: KilocodeProfile): string | undefi * @param token - Authentication token * @param organizationId - Optional organization ID for team balance */ -export async function fetchBalance(token: string, organizationId?: string): Promise { +export async function fetchBalance( + token: string, + organizationId?: string, + log: { warn(message: string, extra?: Record): void } = console, +): Promise { try { const headers: Record = { Authorization: `Bearer ${token}`, @@ -77,14 +81,14 @@ export async function fetchBalance(token: string, organizationId?: string): Prom const response = await fetch(`${KILO_API_BASE}/api/profile/balance`, { headers }) if (!response.ok) { - console.warn(`Failed to fetch balance: ${response.status}`) + log.warn("Failed to fetch balance", { status: response.status }) return null } const data = (await response.json()) as { balance?: number } return { balance: data.balance ?? 0 } } catch (error) { - console.warn("Error fetching balance:", error) + log.warn("Error fetching balance", { error }) return null } } diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index 9cb6031a93ff..ef33715a3bde 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -74,7 +74,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", try: () => Promise.all([ fetchProfile(info.access), - fetchBalance(info.access, currentOrgId ?? undefined), + fetchBalance(info.access, currentOrgId ?? undefined, log), fetchKiloPassState(info.access), ]), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts index 5eeab504f13c..35055be498c9 100644 --- a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts +++ b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts @@ -1,7 +1,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { NodeHttpServer } from "@effect/platform-node" import { Database } from "@opencode-ai/core/database/database" -import { describe, expect } from "bun:test" +import * as Log from "@opencode-ai/core/util/log" +import { describe, expect, spyOn } from "bun:test" import { Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" @@ -73,7 +74,7 @@ const layer = HttpRouter.serve( ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) const it = testEffect(layer) -function stub(run: () => Response | Promise) { +function stub(run: (url: string) => Response | Promise) { // These tests run sequentially; scope the process-global override and delegate in-process server traffic. const original = globalThis.fetch const fetch: typeof globalThis.fetch = Object.assign( @@ -83,7 +84,7 @@ function stub(run: () => Response | Promise) { if (url.startsWith("http://127.0.0.1:") && headers.get("authorization") !== "Bearer test-token") { return original(input, init) } - return run() + return run(url) }, { preconnect: original.preconnect }, ) @@ -103,6 +104,66 @@ function post(path: string, body: Record) { } describe("Kilo gateway HttpApi statuses", () => { + const error = new Error("ConnectionRefused") + for (const failure of [ + { + name: "network error", + run: () => Promise.reject(error), + message: "Error fetching balance", + extra: { error }, + }, + { + name: "HTTP error", + run: () => new Response(null, { status: 503 }), + message: "Failed to fetch balance", + extra: { status: 503 }, + }, + { + name: "invalid JSON", + run: () => new Response("invalid"), + message: "Error fetching balance", + extra: { error: expect.any(SyntaxError) }, + }, + ]) { + it.live(`keeps balance failures out of the terminal: ${failure.name}`, () => + Effect.gen(function* () { + const previous = state.info + const spies = yield* Effect.acquireRelease( + Effect.sync(() => { + state.info = new Auth.Oauth({ type: "oauth", access: "test-token", refresh: "", expires: 0 }) + return { + console: spyOn(console, "warn").mockImplementation(() => {}), + log: spyOn(Log.create({ service: "kilo-gateway" }), "warn").mockImplementation(() => {}), + } + }), + (spies) => + Effect.sync(() => { + state.info = previous + spies.console.mockRestore() + spies.log.mockRestore() + }), + ) + yield* stub((url) => { + if (new URL(url).pathname === "/api/profile/balance") return failure.run() + if (new URL(url).pathname === "/api/profile") return Response.json({ email: "test@example.com" }) + return Response.json({ subscription: null }) + }) + + const response = yield* HttpClient.get(KiloGatewayPaths.profile) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + profile: { email: "test@example.com" }, + balance: null, + kiloPass: null, + currentOrgId: null, + }) + expect(spies.console).not.toHaveBeenCalled() + expect(spies.log.mock.calls).toEqual([[failure.message, failure.extra]]) + }), + ) + } + it.live("reports locally stored API authentication without a Gateway request", () => Effect.gen(function* () { yield* stub(() => Promise.reject(new Error("unexpected Gateway request")))