Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-balance-errors.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions packages/kilo-gateway/src/api/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KilocodeBalance | null> {
export async function fetchBalance(
token: string,
organizationId?: string,
log: { warn(message: string, extra?: Record<string, unknown>): void } = console,
): Promise<KilocodeBalance | null> {
try {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -73,7 +74,7 @@ const layer = HttpRouter.serve(
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const it = testEffect(layer)

function stub(run: () => Response | Promise<Response>) {
function stub(run: (url: string) => Response | Promise<Response>) {
// 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(
Expand All @@ -83,7 +84,7 @@ function stub(run: () => Response | Promise<Response>) {
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 },
)
Expand All @@ -103,6 +104,66 @@ function post(path: string, body: Record<string, unknown>) {
}

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")))
Expand Down
Loading