Skip to content

Commit 3466dbc

Browse files
suryaiyer95mdesmet
authored andcommitted
feat: add Altimate provider with /login command and credential validation
1 parent 5d0ada3 commit 3466dbc

File tree

7 files changed

+416
-1
lines changed

7 files changed

+416
-1
lines changed

docs/docs/getting-started.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ Add a warehouse connection to `.altimate-code/connections.json`. Here's a quick
8181

8282
For all warehouse types (Snowflake, BigQuery, Databricks, PostgreSQL, Redshift, DuckDB, MySQL, SQL Server) and advanced options (key-pair auth, ADC, SSH tunneling), see the [Warehouses reference](configure/warehouses.md).
8383

84+
### Connecting to Altimate
85+
86+
If you have an Altimate platform account, create `~/.altimate/altimate.json` with your credentials:
87+
88+
```json
89+
{
90+
"altimateInstanceName": "your-instance",
91+
"altimateApiKey": "your-api-key",
92+
"altimateUrl": "https://api.myaltimate.com"
93+
}
94+
```
95+
96+
- **Instance Name** — the subdomain from your Altimate dashboard URL (e.g. `acme` from `https://acme.app.myaltimate.com`)
97+
- **API Key** — go to **Settings > API Keys** in your Altimate dashboard and click **Copy**
98+
- **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`
99+
100+
Then run `/connect` and select **Altimate** to start using it as your model.
101+
84102
## Step 4: Choose an Agent Mode
85103

86104
altimate offers specialized agent modes for different workflows:

packages/opencode/src/altimate/api/client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,26 @@ export namespace AltimateApi {
5454
return Filesystem.exists(credentialsPath())
5555
}
5656

57+
function resolveEnvVars(obj: unknown): unknown {
58+
if (typeof obj === "string") {
59+
return obj.replace(/\$\{env:([^}]+)\}/g, (_, envVar) => {
60+
const value = process.env[envVar]
61+
if (!value) throw new Error(`Environment variable ${envVar} not found`)
62+
return value
63+
})
64+
}
65+
if (Array.isArray(obj)) return obj.map(resolveEnvVars)
66+
if (obj && typeof obj === "object")
67+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveEnvVars(v)]))
68+
return obj
69+
}
70+
5771
export async function getCredentials(): Promise<AltimateCredentials> {
5872
const p = credentialsPath()
5973
if (!(await Filesystem.exists(p))) {
6074
throw new Error(`Altimate credentials not found at ${p}`)
6175
}
62-
const raw = JSON.parse(await Filesystem.readText(p))
76+
const raw = resolveEnvVars(JSON.parse(await Filesystem.readText(p)))
6377
return AltimateCredentials.parse(raw)
6478
}
6579

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ 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+
// altimate_change start — Altimate backend provider: import login dialog
17+
import { DialogAltimateLogin } from "./dialog-altimate-login"
18+
// altimate_change end
1619

1720
const PROVIDER_PRIORITY: Record<string, number> = {
1821
opencode: 0,
@@ -21,6 +24,9 @@ const PROVIDER_PRIORITY: Record<string, number> = {
2124
"github-copilot": 3,
2225
anthropic: 4,
2326
google: 5,
27+
// altimate_change start — Altimate backend provider: priority entry
28+
"altimate-backend": 6,
29+
// altimate_change end
2430
}
2531

2632
export function createDialogProviderOptions() {
@@ -35,13 +41,22 @@ export function createDialogProviderOptions() {
3541
title: provider.name,
3642
value: provider.id,
3743
description: {
44+
// altimate_change start — Altimate backend provider: description label
45+
"altimate-backend": "(API key)",
46+
// altimate_change end
3847
opencode: "(Recommended)",
3948
anthropic: "(API key)",
4049
openai: "(ChatGPT Plus/Pro or API key)",
4150
"opencode-go": "Low cost subscription for everyone",
4251
}[provider.id],
4352
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
4453
async onSelect() {
54+
// altimate_change start — Altimate backend provider: route to login dialog
55+
if (provider.id === "altimate-backend") {
56+
dialog.replace(() => <DialogAltimateLogin />)
57+
return
58+
}
59+
// altimate_change end
4560
const methods = sync.data.provider_auth[provider.id] ?? [
4661
{
4762
type: "api",

packages/opencode/src/provider/provider.ts

Lines changed: 62 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"
@@ -181,6 +182,28 @@ export namespace Provider {
181182
options: hasKey ? {} : { apiKey: "public" },
182183
}
183184
},
185+
// altimate_change start — Altimate backend provider: autoload handler reads credentials from ~/.altimate/altimate.json
186+
"altimate-backend": async () => {
187+
const isConfigured = await AltimateApi.isConfigured()
188+
if (!isConfigured) return { autoload: false }
189+
190+
try {
191+
const creds = await AltimateApi.getCredentials()
192+
return {
193+
autoload: true,
194+
options: {
195+
baseURL: `${creds.altimateUrl.replace(/\/+$/, "")}/agents/v1`,
196+
apiKey: creds.altimateApiKey,
197+
headers: {
198+
"x-tenant": creds.altimateInstanceName,
199+
},
200+
},
201+
}
202+
} catch {
203+
return { autoload: false }
204+
}
205+
},
206+
// altimate_change end
184207
openai: async () => {
185208
return {
186209
autoload: false,
@@ -973,6 +996,45 @@ export namespace Provider {
973996
}
974997
// altimate_change end
975998

999+
// altimate_change start — register altimate-backend as an OpenAI-compatible provider
1000+
if (!database["altimate-backend"]) {
1001+
const backendModels: Record<string, Model> = {
1002+
"altimate-default": {
1003+
id: ModelID.make("altimate-default"),
1004+
providerID: ProviderID.make("altimate-backend"),
1005+
name: "Altimate AI",
1006+
family: "openai",
1007+
api: { id: "altimate-default", url: "", npm: "@ai-sdk/openai-compatible" },
1008+
status: "active",
1009+
headers: {},
1010+
options: {},
1011+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
1012+
limit: { context: 200000, output: 128000 },
1013+
capabilities: {
1014+
temperature: true,
1015+
reasoning: false,
1016+
attachment: false,
1017+
toolcall: true,
1018+
input: { text: true, audio: false, image: true, video: false, pdf: false },
1019+
output: { text: true, audio: false, image: false, video: false, pdf: false },
1020+
interleaved: false,
1021+
},
1022+
release_date: "2025-01-01",
1023+
variants: {},
1024+
},
1025+
}
1026+
database["altimate-backend"] = {
1027+
id: ProviderID.make("altimate-backend"),
1028+
name: "Altimate",
1029+
source: "custom",
1030+
env: [],
1031+
options: {},
1032+
models: backendModels,
1033+
}
1034+
}
1035+
// altimate_change end
1036+
1037+
9761038
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
9771039
const existing = providers[providerID]
9781040
if (existing) {

packages/opencode/src/util/filesystem.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export namespace Filesystem {
5555
try {
5656
if (mode) {
5757
await writeFile(p, content, { mode })
58+
// altimate_change start — upstream_fix: writeFile { mode } option does not reliably set permissions; explicit chmod ensures correct mode is applied
59+
await chmod(p, mode)
60+
// altimate_change end
5861
} else {
5962
await writeFile(p, content)
6063
}
@@ -63,6 +66,9 @@ export namespace Filesystem {
6366
await mkdir(dirname(p), { recursive: true })
6467
if (mode) {
6568
await writeFile(p, content, { mode })
69+
// altimate_change start — upstream_fix: writeFile { mode } option does not reliably set permissions; explicit chmod ensures correct mode is applied
70+
await chmod(p, mode)
71+
// altimate_change end
6672
} else {
6773
await writeFile(p, content)
6874
}

0 commit comments

Comments
 (0)