Skip to content

Commit 8d22792

Browse files
committed
feat: add Altimate provider with /login command and credential validation
1 parent 1ca92bc commit 8d22792

File tree

5 files changed

+294
-1
lines changed

5 files changed

+294
-1
lines changed

docs/docs/getting-started.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,35 @@ The TUI launches with an interactive terminal. On first run, use the `/discover`
4646

4747
You can also configure connections manually — see [Warehouse connections](#warehouse-connections) below.
4848

49-
To set up your LLM provider, use the `/connect` command.
49+
### Connecting an LLM provider
50+
51+
Run `/connect` in the TUI to set up your LLM provider. You'll see a list of popular providers — select one and follow the prompts:
52+
53+
- **Altimate Code Zen** (Recommended) — single API key for all top coding models at the lowest prices. Get a key at `https://altimate.ai/zen`
54+
- **OpenAI** — ChatGPT Plus/Pro subscription or API key
55+
- **Anthropic** — API key from `console.anthropic.com`
56+
- **Google** — API key from Google AI Studio
57+
- **Altimate** — connect to your Altimate platform instance (see below)
58+
59+
You can switch providers at any time by running `/connect` again.
60+
61+
### Connecting to Altimate
62+
63+
If you have an Altimate platform account, create `~/.altimate/altimate.json` with your credentials:
64+
65+
```json
66+
{
67+
"altimateInstanceName": "your-instance",
68+
"altimateApiKey": "your-api-key",
69+
"altimateUrl": "https://api.myaltimate.com"
70+
}
71+
```
72+
73+
- **Instance Name** — the subdomain from your Altimate dashboard URL (e.g. `acme` from `https://acme.app.myaltimate.com`)
74+
- **API Key** — go to **Settings > API Keys** in your Altimate dashboard and click **Copy**
75+
- **URL** — matches your dashboard domain: if you access `https://<instance>.app.myaltimate.com`, use `https://api.myaltimate.com`; if `https://<instance>.app.getaltimate.com`, use `https://api.getaltimate.com`
76+
77+
Then run `/connect` and select **Altimate** to start using it as your model.
5078

5179
## Configuration
5280

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { createSignal, Show, onMount } from "solid-js"
2+
import { createStore } from "solid-js/store"
3+
import { TextareaRenderable, TextAttributes } from "@opentui/core"
4+
import { useKeyboard } from "@opentui/solid"
5+
import { useDialog } from "@tui/ui/dialog"
6+
import { useSDK } from "../context/sdk"
7+
import { useSync } from "@tui/context/sync"
8+
import { useTheme } from "../context/theme"
9+
import { AltimateApi } from "@/altimate/api/client"
10+
import { Filesystem } from "@/util/filesystem"
11+
12+
export function DialogAltimateLogin() {
13+
const dialog = useDialog()
14+
const sdk = useSDK()
15+
const sync = useSync()
16+
const { theme } = useTheme()
17+
const [error, setError] = createSignal("")
18+
const [validating, setValidating] = createSignal(false)
19+
const [store, setStore] = createStore({
20+
active: "instance" as "instance" | "key" | "url",
21+
})
22+
23+
let instanceRef: TextareaRenderable
24+
let keyRef: TextareaRenderable
25+
let urlRef: TextareaRenderable
26+
27+
const fields = ["instance", "key", "url"] as const
28+
29+
function focusActive() {
30+
setTimeout(() => {
31+
const ref = { instance: instanceRef, key: keyRef, url: urlRef }[store.active]
32+
if (ref && !ref.isDestroyed) ref.focus()
33+
}, 1)
34+
}
35+
36+
useKeyboard((evt) => {
37+
if (evt.name === "tab") {
38+
const idx = fields.indexOf(store.active)
39+
const next = fields[(idx + 1) % fields.length]
40+
setStore("active", next)
41+
focusActive()
42+
evt.preventDefault()
43+
}
44+
if (evt.name === "return") {
45+
if (validating()) return
46+
void submit().catch((e) => setError(`Unexpected error: ${e?.message ?? e}`))
47+
}
48+
})
49+
50+
onMount(() => {
51+
dialog.setSize("medium")
52+
focusActive()
53+
})
54+
55+
async function submit() {
56+
const instance = instanceRef.plainText.trim()
57+
const key = keyRef.plainText.trim()
58+
const url = urlRef.plainText.trim().replace(/\/+$/, "")
59+
60+
if (!instance) {
61+
setError("Instance name is required")
62+
setStore("active", "instance")
63+
focusActive()
64+
return
65+
}
66+
if (!key) {
67+
setError("API key is required")
68+
setStore("active", "key")
69+
focusActive()
70+
return
71+
}
72+
if (!url) {
73+
setError("URL is required")
74+
setStore("active", "url")
75+
focusActive()
76+
return
77+
}
78+
79+
setError("")
80+
setValidating(true)
81+
try {
82+
const res = await fetch(`${url}/auth_health`, {
83+
method: "GET",
84+
headers: {
85+
Authorization: `Bearer ${key}`,
86+
"x-tenant": instance,
87+
},
88+
})
89+
if (!res.ok) {
90+
setError("Invalid credentials — check your instance name, API key, and URL")
91+
setValidating(false)
92+
return
93+
}
94+
const data = await res.json()
95+
if (data.status !== "auth_valid") {
96+
setError("Unexpected response from server")
97+
setValidating(false)
98+
return
99+
}
100+
} catch {
101+
setError(`Connection failed — could not reach ${url}`)
102+
setValidating(false)
103+
return
104+
}
105+
106+
try {
107+
const creds = {
108+
altimateUrl: url,
109+
altimateInstanceName: instance,
110+
altimateApiKey: key,
111+
}
112+
await Filesystem.writeJson(AltimateApi.credentialsPath(), creds, 0o600)
113+
await sdk.client.instance.dispose()
114+
await sync.bootstrap()
115+
dialog.clear()
116+
} finally {
117+
setValidating(false)
118+
}
119+
}
120+
121+
return (
122+
<box paddingLeft={2} paddingRight={2} gap={1}>
123+
<box flexDirection="row" justifyContent="space-between">
124+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
125+
Connect to Altimate
126+
</text>
127+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
128+
esc
129+
</text>
130+
</box>
131+
132+
<text fg={theme.textMuted}>Find these in Settings &gt; API Keys in your Altimate dashboard</text>
133+
134+
<box>
135+
<text fg={store.active === "instance" ? theme.text : theme.textMuted}>Instance Name:</text>
136+
<text fg={theme.textMuted}> From your URL: https://&lt;instance&gt;.app.myaltimate.com</text>
137+
<textarea
138+
height={3}
139+
ref={(val: TextareaRenderable) => (instanceRef = val)}
140+
placeholder="your-instance"
141+
textColor={theme.text}
142+
focusedTextColor={theme.text}
143+
cursorColor={theme.text}
144+
onMouseUp={() => {
145+
setStore("active", "instance")
146+
focusActive()
147+
}}
148+
/>
149+
</box>
150+
151+
<box>
152+
<text fg={store.active === "key" ? theme.text : theme.textMuted}>API Key:</text>
153+
<text fg={theme.textMuted}> Settings &gt; API Keys &gt; Copy</text>
154+
<textarea
155+
height={3}
156+
ref={(val: TextareaRenderable) => (keyRef = val)}
157+
placeholder="your-api-key"
158+
textColor={theme.text}
159+
focusedTextColor={theme.text}
160+
cursorColor={theme.text}
161+
onMouseUp={() => {
162+
setStore("active", "key")
163+
focusActive()
164+
}}
165+
/>
166+
</box>
167+
168+
<box>
169+
<text fg={store.active === "url" ? theme.text : theme.textMuted}>URL:</text>
170+
<textarea
171+
height={3}
172+
ref={(val: TextareaRenderable) => (urlRef = val)}
173+
initialValue="https://api.myaltimate.com"
174+
placeholder="https://api.myaltimate.com"
175+
textColor={theme.text}
176+
focusedTextColor={theme.text}
177+
cursorColor={theme.text}
178+
onMouseUp={() => {
179+
setStore("active", "url")
180+
focusActive()
181+
}}
182+
/>
183+
</box>
184+
185+
<Show when={error()}>
186+
<text fg={theme.error}>{error()}</text>
187+
</Show>
188+
<Show when={validating()}>
189+
<text fg={theme.textMuted}>Validating credentials...</text>
190+
</Show>
191+
192+
<text fg={theme.textMuted} paddingBottom={1}>
193+
<span style={{ fg: theme.text }}>tab</span> next field{" "}
194+
<span style={{ fg: theme.text }}>enter</span> submit
195+
</text>
196+
</box>
197+
)
198+
}

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DialogModel } from "./dialog-model"
1313
import { useKeyboard } from "@opentui/solid"
1414
import { Clipboard } from "@tui/util/clipboard"
1515
import { useToast } from "../ui/toast"
16+
import { DialogAltimateLogin } from "./dialog-altimate-login"
1617

1718
const PROVIDER_PRIORITY: Record<string, number> = {
1819
opencode: 0,
@@ -21,6 +22,7 @@ const PROVIDER_PRIORITY: Record<string, number> = {
2122
"github-copilot": 3,
2223
anthropic: 4,
2324
google: 5,
25+
"altimate-backend": 6,
2426
}
2527

2628
export function createDialogProviderOptions() {
@@ -35,13 +37,18 @@ export function createDialogProviderOptions() {
3537
title: provider.name,
3638
value: provider.id,
3739
description: {
40+
"altimate-backend": "(API key)",
3841
opencode: "(Recommended)",
3942
anthropic: "(API key)",
4043
openai: "(ChatGPT Plus/Pro or API key)",
4144
"opencode-go": "Low cost subscription for everyone",
4245
}[provider.id],
4346
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
4447
async onSelect() {
48+
if (provider.id === "altimate-backend") {
49+
dialog.replace(() => <DialogAltimateLogin />)
50+
return
51+
}
4552
const methods = sync.data.provider_auth[provider.id] ?? [
4653
{
4754
type: "api",

packages/opencode/src/provider/provider.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { iife } from "@/util/iife"
1818
import { Global } from "../global"
1919
import path from "path"
2020
import { Filesystem } from "../util/filesystem"
21+
import { AltimateApi } from "../altimate/api/client"
2122

2223
// Direct imports for bundled providers
2324
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -178,6 +179,26 @@ export namespace Provider {
178179
options: hasKey ? {} : { apiKey: "public" },
179180
}
180181
},
182+
"altimate-backend": async () => {
183+
const isConfigured = await AltimateApi.isConfigured()
184+
if (!isConfigured) return { autoload: false }
185+
186+
try {
187+
const creds = await AltimateApi.getCredentials()
188+
return {
189+
autoload: true,
190+
options: {
191+
baseURL: `${creds.altimateUrl.replace(/\/+$/, "")}/agents/v1`,
192+
apiKey: creds.altimateApiKey,
193+
headers: {
194+
"x-tenant": creds.altimateInstanceName,
195+
},
196+
},
197+
}
198+
} catch {
199+
return { autoload: false }
200+
}
201+
},
181202
openai: async () => {
182203
return {
183204
autoload: false,
@@ -877,6 +898,43 @@ export namespace Provider {
877898
}
878899
}
879900

901+
// Register altimate-backend as an OpenAI-compatible provider
902+
if (!database["altimate-backend"]) {
903+
const backendModels: Record<string, Model> = {
904+
"altimate-default": {
905+
id: ModelID.make("altimate-default"),
906+
providerID: ProviderID.make("altimate-backend"),
907+
name: "Altimate AI",
908+
family: "openai",
909+
api: { id: "altimate-default", url: "", npm: "@ai-sdk/openai-compatible" },
910+
status: "active",
911+
headers: {},
912+
options: {},
913+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
914+
limit: { context: 200000, output: 128000 },
915+
capabilities: {
916+
temperature: true,
917+
reasoning: false,
918+
attachment: false,
919+
toolcall: true,
920+
input: { text: true, audio: false, image: true, video: false, pdf: false },
921+
output: { text: true, audio: false, image: false, video: false, pdf: false },
922+
interleaved: false,
923+
},
924+
release_date: "2025-01-01",
925+
variants: {},
926+
},
927+
}
928+
database["altimate-backend"] = {
929+
id: ProviderID.make("altimate-backend"),
930+
name: "Altimate",
931+
source: "custom",
932+
env: [],
933+
options: {},
934+
models: backendModels,
935+
}
936+
}
937+
880938
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
881939
const existing = providers[providerID]
882940
if (existing) {

packages/opencode/src/util/filesystem.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export namespace Filesystem {
5555
try {
5656
if (mode) {
5757
await writeFile(p, content, { mode })
58+
await chmod(p, mode)
5859
} else {
5960
await writeFile(p, content)
6061
}
@@ -63,6 +64,7 @@ export namespace Filesystem {
6364
await mkdir(dirname(p), { recursive: true })
6465
if (mode) {
6566
await writeFile(p, content, { mode })
67+
await chmod(p, mode)
6668
} else {
6769
await writeFile(p, content)
6870
}

0 commit comments

Comments
 (0)