fix(billing): hand off billing to hosted console - #408
Conversation
PeronGH
left a comment
There was a problem hiding this comment.
Two correctness issues on the typed gateway-billing path; details inline.
| if ( | ||
| this.emitGatewayError({ | ||
| statusCode: msg.api_error_status, | ||
| message: msg.result, | ||
| }) | ||
| ) { | ||
| this.teardown(); | ||
| if (!cancelling) this.emitStatus('idle'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
This can never fire, so claude-code never gets a typed billing error.
The LinkCode Gateway account is protocol: 'openai-chat' (catalog.ts), which is exactly the combination translationUpstream() claims: packages/host/engine/src/agent/translator.ts:35-37 matches kind === 'claude-code' + config.protocol === 'openai-chat', and SessionStartOptionsResolver.resolve (session/start-options-resolver.ts:46-63) then runs withTranslatorEndpoint, which overwrites config.baseUrl with the loopback sidecar URL and sets protocol: 'anthropic'. By the time start() stores opts, this.opts.config.baseUrl is http://127.0.0.1:PORT, so gatewayHost() in linkCodeGatewayError is always false here — this block, and the emitProviderError on the non-success path below, are dead for gateway sessions. Codex/opencode/pi are unaffected because translationUpstream returns undefined for them and their baseUrl stays the gateway host.
Net effect: on the flagship agent a gateway 402 surfaces as raw error text with no top-up button. The upstream URL is spread away by withTranslatorEndpoint, so nothing downstream can recover it — the fix belongs in the resolver (carry the pre-rewrite upstream in config, e.g. upstreamBaseUrl, and have linkCodeGatewayError consult it). Worth confirming first that the arcboxlabs/aigateway sidecar even forwards the upstream 402 status/body, otherwise the typed error can't be reconstructed on this path at all.
Separately, and regardless of how the routing is resolved: msg.result is the final assistant text on subtype === 'success', and typedCode substring-matches it for insufficient_credits. Once host detection starts working here, a reply that merely mentions the token converts a successful turn into an error and returns before emitUsage/emitStop, losing the turn's usage and stop event. Only statusCode is trustworthy on the success path — drop message: msg.result from this call.
PeronGH
left a comment
There was a problem hiding this comment.
Correction to my earlier 503 comment (retracted inline — the mapping is contract-backed), and the real problem the Cloud contract exposes instead.
| insufficientCreditsHint: 'Top up your balance, then retry this message.', | ||
| topUpCredits: 'Top up credits', | ||
| billingUnavailableTitle: 'LinkCode billing is temporarily unavailable', | ||
| billingUnavailableHint: 'Your request was not sent to the model. Please retry shortly.', |
There was a problem hiding this comment.
This contradicts Cloud's documented 503 semantics. apps/web/content/docs/reference/ai-api-errors.mdx in linkcodehq says of 503 billing_unavailable:
This error can occur either before an upstream call or after a provider response while the charge is being persisted, so do not assume that no completion was generated.
The gateway backs that up: chat-completions.ts:490 returns billing_unavailable on the post-response persistence failure, after the upstream completion already happened (and was possibly already streamed). Telling the user "Your request was not sent to the model" is therefore wrong in that case, and it invites a retry that duplicates a completion the org may already owe for.
Something like "Billing could not record this request — it may or may not have reached the model. Retry shortly." keeps the retry advice without the false guarantee.
insufficientCreditsHint two lines up needs no change: the same doc guarantees 402 is generated before any upstream provider call and that retrying after a top-up is safe.
Summary
@linkcode/cloudSDK.openHostedBilling(): main registers the currentlinkcode:///linkcode-dev://scheme, buildshttps://console.linkcode.ai/billing?returnTarget=..., and opens it in the system browser.BillingSettingsPanel({ onOpenBilling }); LinkCode does not request, store, render, or infer billing/checkout/payment state.Linear: CODE-567
Cloud counterpart: arcboxlabs/linkcodehq#39 at
5438df8c8083f349dc3b8ed3eccdd61ca9d86358Contracts
createHostedBillingUrl(options?: { returnTarget?: string }): string;HostedBillingUrlOptionsis exported.https://console.linkcode.ai/billing[?returnTarget=<encoded>].${scheme}://billing/return, where scheme islinkcodein packaged builds andlinkcode-devin development.order=<id>and, on cancellation,canceled=true. Desktop deliberately does not parse either parameter and does not process checkout; the existing generic deep-link path only activates/focuses the app.@linkcode/cloud@0.1.0, this repository consumes the official package tarball built from the Cloud commit above, pinned at SHA-256576bf95543503615204163609c49fe66ad38a3081babf5b93369a94842acdb7b.Verification
NODE_OPTIONS=--max-old-space-size=6144 pnpm check:ci— passed: format, repo-wide ESLint (0 errors; 377 existing warnings), and typecheck.pnpm test— passed: 307 files / 2443 tests; 1 existing skipped file/test.pnpm -F @linkcode/desktop run build— passed.pnpm -F @linkcode/webview run build— passed.pnpm -F @linkcode/webview e2e:browser— passed production billing-route smoke plus bundled mock router/wire/reload smoke.checkout-sessions, billing summary/orders, or portal-session endpoints.linkcode-dev://billing/return?order=...- Commit hookscanceled=true, observed one restored/focused window, and observed no local checkout-result UI.Checklist
pnpm check:ciandpnpm testboth pass