Skip to content

Commit 0d34855

Browse files
mdesmetsuryaiyer95anandgupta42claude
authored
feat: add Altimate provider with /login command and credential valida… (#606)
* feat: add altimate-backend TUI connect flow with credential validation - Parse url::instance::key from ApiMethod dialog and write ~/.altimate/altimate.json directly - Validate credentials against /dbt/v3/validate-credentials before saving (mirrors altimate-mcp-engine pattern) - Show inline error in dialog on invalid format, bad API key (401), or bad instance name (403) - Register AltimateAuthPlugin to surface "Connect to Altimate" method in /connect dialog - Add altimate-backend CUSTOM_LOADER: file-first, auth-store fallback - Add Filesystem.writeJson helper with mkdir-on-ENOENT and explicit chmod - 47 tests covering parseAltimateKey, saveCredentials, validateCredentials, and TUI round-trip * fix: address CodeRabbit review feedback on Altimate provider PR - `resolveEnvVars`: check `=== undefined` instead of `!value` to allow empty-string env vars - Normalize `altimateUrl` (strip trailing slashes) in both `getCredentials()` and `saveCredentials()` to prevent malformed `//` URLs in `request()` - Include underlying error details in `validateCredentials` catch for easier debugging - Suppress stale `altimate-backend` auth via `Auth.remove()` when the loader cannot build valid options - Wrap `saveCredentials`/`dispose`/`bootstrap` in try-catch in `dialog-provider.tsx` - Document `altimate.json` schema and `${env:VAR_NAME}` substitution in `getting-started.md` - Add `text` language identifier to credential format code block - Update tests for URL normalization and error message changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: guard `Auth.remove()` calls and add empty-string env var test - Add `.catch(() => {})` to `Auth.remove()` in provider.ts to prevent a storage error from crashing all provider initialization - Add test verifying that `${env:VAR}` resolves to `""` when the env var is set to an empty string (covers the `!value` → `=== undefined` change) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: suryaiyer95 <surya@altimate.ai> Co-authored-by: anandgupta42 <93243293+anandgupta42@users.noreply.github.com> Co-authored-by: anandgupta42 <anand@altimate.ai> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 31d163e commit 0d34855

File tree

8 files changed

+697
-4
lines changed

8 files changed

+697
-4
lines changed

docs/docs/getting-started.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,50 @@ 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, run `/connect` in the TUI, select **Altimate**, and enter your credentials in this format:
87+
88+
```text
89+
instance-url::instance-name::api-key
90+
```
91+
92+
For example: `https://api.getaltimate.com::acme::your-api-key`
93+
94+
- **Instance URL**`https://api.myaltimate.com` or `https://api.getaltimate.com` depending on your dashboard domain
95+
- **Instance Name** — the subdomain from your Altimate dashboard URL (e.g. `acme` from `https://acme.app.myaltimate.com`)
96+
- **API Key** — go to **Settings > API Keys** in your Altimate dashboard and click **Copy**
97+
98+
Credentials are validated against the Altimate API before being saved. If you prefer to configure credentials directly (e.g. for CI or environment variable substitution), you can also create `~/.altimate/altimate.json` manually — if that file exists it takes priority over the TUI-entered credentials.
99+
100+
**`altimate.json` schema:**
101+
102+
```json
103+
{
104+
"altimateUrl": "https://api.myaltimate.com",
105+
"altimateInstanceName": "acme",
106+
"altimateApiKey": "your-api-key",
107+
"mcpServerUrl": "https://mcpserver.getaltimate.com/sse"
108+
}
109+
```
110+
111+
| Field | Required | Description |
112+
|---|---|---|
113+
| `altimateUrl` | Yes | Full base URL of the Altimate API |
114+
| `altimateInstanceName` | Yes | Your tenant/instance identifier |
115+
| `altimateApiKey` | Yes | API key from **Settings > API Keys** |
116+
| `mcpServerUrl` | No | Custom MCP server URL (defaults to the hosted endpoint) |
117+
118+
You can use `${env:VAR_NAME}` syntax to reference environment variables instead of hardcoding secrets:
119+
120+
```json
121+
{
122+
"altimateUrl": "https://api.myaltimate.com",
123+
"altimateInstanceName": "acme",
124+
"altimateApiKey": "${env:ALTIMATE_API_KEY}"
125+
}
126+
```
127+
84128
## Step 4: Choose an Agent Mode
85129

86130
altimate offers specialized agent modes for different workflows:

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

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,103 @@ 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 === undefined) 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))
63-
return AltimateCredentials.parse(raw)
76+
const raw = resolveEnvVars(JSON.parse(await Filesystem.readText(p)))
77+
const creds = AltimateCredentials.parse(raw)
78+
return {
79+
...creds,
80+
altimateUrl: creds.altimateUrl.replace(/\/+$/, ""),
81+
}
82+
}
83+
84+
export function parseAltimateKey(value: string): {
85+
altimateUrl: string
86+
altimateInstanceName: string
87+
altimateApiKey: string
88+
} | null {
89+
const parts = value.trim().split("::")
90+
if (parts.length < 3) return null
91+
const url = parts[0].trim()
92+
const instance = parts[1].trim()
93+
const key = parts.slice(2).join("::").trim()
94+
if (!url || !instance || !key) return null
95+
if (!url.startsWith("http://") && !url.startsWith("https://")) return null
96+
return { altimateUrl: url, altimateInstanceName: instance, altimateApiKey: key }
97+
}
98+
99+
export async function saveCredentials(creds: {
100+
altimateUrl: string
101+
altimateInstanceName: string
102+
altimateApiKey: string
103+
mcpServerUrl?: string
104+
}): Promise<void> {
105+
await Filesystem.writeJson(
106+
credentialsPath(),
107+
{ ...creds, altimateUrl: creds.altimateUrl.replace(/\/+$/, "") },
108+
0o600,
109+
)
110+
}
111+
112+
const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/
113+
114+
/** Validates credentials against the Altimate API.
115+
* Mirrors AltimateSettingsHelper.validateSettings from altimate-mcp-engine. */
116+
export async function validateCredentials(creds: {
117+
altimateUrl: string
118+
altimateInstanceName: string
119+
altimateApiKey: string
120+
}): Promise<{ ok: true } | { ok: false; error: string }> {
121+
if (!VALID_TENANT_REGEX.test(creds.altimateInstanceName)) {
122+
return {
123+
ok: false,
124+
error:
125+
"Invalid instance name (must be lowercase letters, numbers, underscores, hyphens, starting with letter or underscore)",
126+
}
127+
}
128+
try {
129+
const url = `${creds.altimateUrl.replace(/\/+$/, "")}/dbt/v3/validate-credentials`
130+
const res = await fetch(url, {
131+
method: "GET",
132+
headers: {
133+
"x-tenant": creds.altimateInstanceName,
134+
Authorization: `Bearer ${creds.altimateApiKey}`,
135+
"Content-Type": "application/json",
136+
},
137+
})
138+
if (res.status === 401) {
139+
const body = await res.text()
140+
return { ok: false, error: `Invalid API key - ${body}` }
141+
}
142+
if (res.status === 403) {
143+
const body = await res.text()
144+
return { ok: false, error: `Invalid instance name - ${body}` }
145+
}
146+
if (!res.ok) {
147+
return { ok: false, error: `Connection failed (${res.status} ${res.statusText})` }
148+
}
149+
return { ok: true }
150+
} catch (err) {
151+
const detail = err instanceof Error ? err.message : String(err)
152+
return { ok: false, error: `Could not reach Altimate API: ${detail}` }
153+
}
64154
}
65155

66156
async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
2+
3+
export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
4+
return {
5+
auth: {
6+
provider: "altimate-backend",
7+
methods: [
8+
{
9+
type: "api",
10+
label: "Connect to Altimate",
11+
},
12+
],
13+
},
14+
}
15+
}

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

Lines changed: 47 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 — import AltimateApi for direct credential file write
17+
import { AltimateApi } from "../../../../altimate/api/client"
18+
// altimate_change end
1619

1720
const PROVIDER_PRIORITY: Record<string, number> = {
1821
opencode: 0,
@@ -210,6 +213,9 @@ function ApiMethod(props: ApiMethodProps) {
210213
const sdk = useSDK()
211214
const sync = useSync()
212215
const { theme } = useTheme()
216+
// altimate_change start — altimate-backend: validation error signal
217+
const [validationError, setValidationError] = createSignal<string | null>(null)
218+
// altimate_change end
213219

214220
return (
215221
<DialogPrompt
@@ -239,10 +245,51 @@ function ApiMethod(props: ApiMethodProps) {
239245
</text>
240246
</box>
241247
),
248+
// altimate_change start — altimate-backend credential format description
249+
"altimate-backend": (
250+
<box gap={1}>
251+
<text fg={theme.textMuted}>
252+
Enter your Altimate credentials in this format:
253+
</text>
254+
<text fg={theme.text}>
255+
instance-url::instance-name::api-key
256+
</text>
257+
<text fg={theme.textMuted}>
258+
e.g. https://api.getaltimate.com::mycompany::abc123
259+
</text>
260+
<Show when={validationError()}>
261+
<text fg={theme.error}>{validationError()!}</text>
262+
</Show>
263+
</box>
264+
),
265+
// altimate_change end
242266
}[props.providerID] ?? undefined
243267
}
244268
onConfirm={async (value) => {
245269
if (!value) return
270+
// altimate_change start — altimate-backend: validate then write credentials file directly
271+
if (props.providerID === "altimate-backend") {
272+
const parsed = AltimateApi.parseAltimateKey(value)
273+
if (!parsed) {
274+
setValidationError("Invalid format — use: instance-url::instance-name::api-key")
275+
return
276+
}
277+
const validation = await AltimateApi.validateCredentials(parsed)
278+
if (!validation.ok) {
279+
setValidationError(validation.error)
280+
return
281+
}
282+
try {
283+
await AltimateApi.saveCredentials(parsed)
284+
await sdk.client.instance.dispose()
285+
await sync.bootstrap()
286+
dialog.replace(() => <DialogModel providerID={props.providerID} />)
287+
} catch (err) {
288+
setValidationError(err instanceof Error ? err.message : "Failed to save credentials")
289+
}
290+
return
291+
}
292+
// altimate_change end
246293
await sdk.client.auth.set({
247294
providerID: props.providerID,
248295
auth: {

packages/opencode/src/plugin/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-au
1515
// altimate_change start — snowflake cortex plugin import
1616
import { SnowflakeCortexAuthPlugin } from "../altimate/plugin/snowflake"
1717
// altimate_change end
18+
// altimate_change start — altimate backend auth plugin
19+
import { AltimateAuthPlugin } from "../altimate/plugin/altimate"
20+
// altimate_change end
1821

1922
export namespace Plugin {
2023
const log = Log.create({ service: "plugin" })
@@ -25,8 +28,8 @@ export namespace Plugin {
2528
// GitlabAuthPlugin uses a different version of @opencode-ai/plugin (from npm)
2629
// vs the workspace version, causing a type mismatch on internal HeyApiClient.
2730
// The types are structurally compatible at runtime.
28-
// altimate_change start — snowflake cortex internal plugin
29-
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin]
31+
// altimate_change start — snowflake cortex and altimate backend internal plugins
32+
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin, AltimateAuthPlugin]
3033
// altimate_change end
3134

3235
const state = Instance.state(async () => {

packages/opencode/src/provider/provider.ts

Lines changed: 85 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,51 @@ export namespace Provider {
181182
options: hasKey ? {} : { apiKey: "public" },
182183
}
183184
},
185+
// altimate_change start — Altimate backend provider: ~/.altimate/altimate.json first, auth store (TUI-configured) as fallback
186+
"altimate-backend": async () => {
187+
// Path 1: ~/.altimate/altimate.json (primary — manual file or env-var substitution, never overwritten)
188+
const isConfigured = await AltimateApi.isConfigured()
189+
if (isConfigured) {
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+
// Suppress stale auth so the generic Auth.all() merge doesn't leave an invalid provider
204+
await Auth.remove(ProviderID.make("altimate-backend")).catch(() => {})
205+
return { autoload: false }
206+
}
207+
}
208+
// Path 2: auth store (populated by TUI entry, file not yet written)
209+
const auth = await Auth.get(ProviderID.make("altimate-backend"))
210+
if (auth?.type === "api") {
211+
const parsed = AltimateApi.parseAltimateKey(auth.key)
212+
if (parsed) {
213+
return {
214+
autoload: true,
215+
options: {
216+
baseURL: `${parsed.altimateUrl.replace(/\/+$/, "")}/agents/v1`,
217+
apiKey: parsed.altimateApiKey,
218+
headers: {
219+
"x-tenant": parsed.altimateInstanceName,
220+
},
221+
},
222+
}
223+
}
224+
// Invalid key format — remove stale auth entry
225+
await Auth.remove(ProviderID.make("altimate-backend")).catch(() => {})
226+
}
227+
return { autoload: false }
228+
},
229+
// altimate_change end
184230
openai: async () => {
185231
return {
186232
autoload: false,
@@ -973,6 +1019,45 @@ export namespace Provider {
9731019
}
9741020
// altimate_change end
9751021

1022+
// altimate_change start — register altimate-backend as an OpenAI-compatible provider
1023+
if (!database["altimate-backend"]) {
1024+
const backendModels: Record<string, Model> = {
1025+
"altimate-default": {
1026+
id: ModelID.make("altimate-default"),
1027+
providerID: ProviderID.make("altimate-backend"),
1028+
name: "Altimate AI",
1029+
family: "openai",
1030+
api: { id: "altimate-default", url: "", npm: "@ai-sdk/openai-compatible" },
1031+
status: "active",
1032+
headers: {},
1033+
options: {},
1034+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
1035+
limit: { context: 200000, output: 128000 },
1036+
capabilities: {
1037+
temperature: true,
1038+
reasoning: false,
1039+
attachment: false,
1040+
toolcall: true,
1041+
input: { text: true, audio: false, image: true, video: false, pdf: false },
1042+
output: { text: true, audio: false, image: false, video: false, pdf: false },
1043+
interleaved: false,
1044+
},
1045+
release_date: "2025-01-01",
1046+
variants: {},
1047+
},
1048+
}
1049+
database["altimate-backend"] = {
1050+
id: ProviderID.make("altimate-backend"),
1051+
name: "Altimate",
1052+
source: "custom",
1053+
env: [],
1054+
options: {},
1055+
models: backendModels,
1056+
}
1057+
}
1058+
// altimate_change end
1059+
1060+
9761061
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
9771062
const existing = providers[providerID]
9781063
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)