onehouse-cli 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/PKG-INFO +72 -4
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/README.md +69 -2
- onehouse_cli-0.4.0/onehouse_cli/__init__.py +32 -0
- onehouse_cli-0.4.0/onehouse_cli/ai_gateway_proxy.py +229 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/cli.py +25 -3
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/client.py +13 -1
- onehouse_cli-0.4.0/onehouse_cli/commands/ai_gateway.py +321 -0
- onehouse_cli-0.4.0/onehouse_cli/commands/auth.py +75 -0
- onehouse_cli-0.4.0/onehouse_cli/commands/install.py +77 -0
- onehouse_cli-0.4.0/onehouse_cli/commands/skill.py +86 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/configure.py +30 -21
- onehouse_cli-0.4.0/onehouse_cli/profile.py +53 -0
- onehouse_cli-0.4.0/onehouse_cli/session_store.py +111 -0
- onehouse_cli-0.4.0/onehouse_cli/skills/onehouse-cli/SKILL.md +525 -0
- onehouse_cli-0.4.0/onehouse_cli/skills/onehouse-cli/references/command-map.md +824 -0
- onehouse_cli-0.4.0/onehouse_cli/skills/onehouse-cli/references/docs-links.md +134 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/PKG-INFO +72 -4
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/SOURCES.txt +11 -1
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/entry_points.txt +1 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/requires.txt +2 -1
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/pyproject.toml +10 -2
- onehouse_cli-0.2.0/onehouse_cli/__init__.py +0 -21
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/__init__.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/_base.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/api_token.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/catalog.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/cluster.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/database.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/flow.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/job.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/lake.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/service_principal.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/source.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/sql.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/table.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/transformation.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/validation.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/output.py +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/dependency_links.txt +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/top_level.txt +0 -0
- {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: onehouse-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A command-line tool for the full Onehouse API
|
|
5
5
|
Author-email: Sagar Lakshmipathy <18vidhyasagar@gmail.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -16,7 +16,8 @@ Requires-Python: >=3.9
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
Requires-Dist: click>=8.0
|
|
18
18
|
Requires-Dist: requests>=2.28
|
|
19
|
-
Requires-Dist: onehouse-python-sdk[resources]
|
|
19
|
+
Requires-Dist: onehouse-python-sdk[resources]>=0.3.0
|
|
20
|
+
Requires-Dist: keyring>=24
|
|
20
21
|
Provides-Extra: dev
|
|
21
22
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
23
|
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
@@ -33,6 +34,20 @@ pip install onehouse-cli
|
|
|
33
34
|
|
|
34
35
|
The SDK dependency (`onehouse-python-sdk[resources]`) is installed automatically.
|
|
35
36
|
|
|
37
|
+
## AI agent integration
|
|
38
|
+
|
|
39
|
+
The package bundles a skill that teaches AI coding agents how to use `oh`. Install it for your agent:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
oh install agent-skill --claude # ~/.claude/skills/onehouse-cli
|
|
43
|
+
oh install agent-skill --cursor # ~/.cursor/skills/onehouse-cli
|
|
44
|
+
oh install agent-skill --codex # ~/.agents/skills/onehouse-cli
|
|
45
|
+
oh install agent-skill --all # all of the above
|
|
46
|
+
oh install agent-skill --all --dry-run
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Re-run the command after `pip install --upgrade onehouse-cli` to refresh the skill. To read the skill without installing it, run `oh skill`.
|
|
50
|
+
|
|
36
51
|
## Setup
|
|
37
52
|
|
|
38
53
|
**Configure credentials:**
|
|
@@ -46,11 +61,12 @@ Fields collected:
|
|
|
46
61
|
- Profile name
|
|
47
62
|
- Account UID
|
|
48
63
|
- Project UID
|
|
49
|
-
- API Key / API Secret
|
|
64
|
+
- API Key / API Secret (blank if you use `oh login`)
|
|
50
65
|
- Project Region
|
|
51
66
|
- User ID
|
|
52
67
|
- Request ID (link UID)
|
|
53
|
-
- API Endpoint (default: `https://api.onehouse.ai
|
|
68
|
+
- API Endpoint (default: `https://api.onehouse.ai`)
|
|
69
|
+
- AI Gateway URL (optional; only `oh ai-gateway` reads it)
|
|
54
70
|
|
|
55
71
|
**Credentials are stored in:** `~/.onehouse/credentials`
|
|
56
72
|
|
|
@@ -66,6 +82,58 @@ export ONEHOUSE_LINK_UID=...
|
|
|
66
82
|
export ONEHOUSE_PROFILE=myprofile # select a profile from the credentials file
|
|
67
83
|
```
|
|
68
84
|
|
|
85
|
+
## Sign in instead of an API key (`oh login`)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
oh login # opens your browser; commands then run as you
|
|
89
|
+
oh logout # revokes the session and forgets it
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- Your organization must have OAuth sign-in for the API turned on. Ask your Onehouse contact.
|
|
93
|
+
- The profile still needs Account UID, Project UID, Region and Request ID (link UID). It does not need an API key, secret or User ID: `oh-configure` accepts them blank.
|
|
94
|
+
- While you are signed in, commands use your session instead of the profile's API key and secret. `oh logout` switches back.
|
|
95
|
+
- The session renews itself for 30 days. The refresh token is kept in your OS keychain. A machine with no keychain, such as headless Linux, keeps it in `~/.onehouse/sessions/` with mode `0600`. `ONEHOUSE_SESSION_STORE=file` forces the file.
|
|
96
|
+
- A machine with no browser uses a service principal's API key, or `oh login --port <n>` over an SSH forward.
|
|
97
|
+
- `oh login` signs in to the Onehouse API only, not to an AI Gateway; that is `oh ai-gateway login`.
|
|
98
|
+
|
|
99
|
+
## AI Gateway: Claude Code and Codex with no pasted token
|
|
100
|
+
|
|
101
|
+
`oh ai-gateway` keeps an AI Gateway token fresh for the whole session. Set the gateway once with `oh-configure` (AI Gateway URL) or pass `--gateway-url`. Everything here needs the VPN or your own network.
|
|
102
|
+
|
|
103
|
+
As a person:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
oh ai-gateway login # once; prints the one-time MCP setup for both clients
|
|
107
|
+
oh ai-gateway run -- claude # every session
|
|
108
|
+
oh ai-gateway run -- codex -m <model alias>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
As a service principal: use a profile with its API key and secret, skip `login`, and add the MCP entry `oh ai-gateway login` would print. In short:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
claude mcp add-json --scope user oh-ai-gateway \
|
|
115
|
+
'{"type":"http","url":"<gateway>/mcp","headersHelper":"oh ai-gateway token --format mcp-headers --gateway-url <gateway>"}'
|
|
116
|
+
oh ai-gateway run -- claude
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For Codex 0.148.0 or later, add to `~/.codex/config.toml`:
|
|
120
|
+
|
|
121
|
+
```toml
|
|
122
|
+
[mcp_servers.oh-ai-gateway]
|
|
123
|
+
url = "<gateway>/mcp"
|
|
124
|
+
http_headers_helper = "oh ai-gateway token --format mcp-headers --gateway-url <gateway>"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- Model calls go through a local proxy that `run` starts on `127.0.0.1` and stops when the client exits. It sets a fresh `X-Onehouse-Authorization` on every request and retries once after a 401. The client gets the proxy's address and a per-run secret in its environment; nothing is copied by hand or written to disk.
|
|
128
|
+
- MCP tools use the `headersHelper` (Claude Code) or `http_headers_helper` (Codex). The client runs it on every connection and again after a 401.
|
|
129
|
+
- Codex runs `http_headers_helper` without your shell's `ONEHOUSE_*` variables. The helper `oh ai-gateway login` prints carries `--profile` and, as an `env` prefix (not on Windows), any `ONEHOUSE_CREDENTIALS_FILE` or `ONEHOUSE_SESSION_STORE` you set. Writing the entry by hand, do the same, and keep a service principal's key and secret in the profile rather than in `ONEHOUSE_API_KEY` and `ONEHOUSE_API_SECRET`.
|
|
130
|
+
- Codex sends only `/v1/responses`, which the gateway passes to the provider as is, so `-m` must name a model alias served by an OpenAI model with the Responses API, such as `gpt-5`. An alias served by Anthropic returns 404, and `gpt-4` fails: it rejects Codex's `web_search_preview` tool and its 8K context is too small.
|
|
131
|
+
- Add the MCP entry at user scope: a project or local-scope `headersHelper` does not run until you accept the folder trust prompt.
|
|
132
|
+
- `oh ai-gateway proxy` runs the proxy on its own for other clients and prints the two variables to set.
|
|
133
|
+
- `oh ai-gateway token` prints a token, and `--format mcp-headers` prints the JSON a headers helper returns.
|
|
134
|
+
- `oh ai-gateway logout` ends the sign-in; the profile's service principal, if any, applies again. Tokens already issued to a person stay valid until they expire, up to 7 days.
|
|
135
|
+
- `run` warns when `ANTHROPIC_API_KEY` is set: it displaces your subscription, and the gateway forwards it upstream as your own key.
|
|
136
|
+
|
|
69
137
|
## Global Options
|
|
70
138
|
|
|
71
139
|
```
|
|
@@ -10,6 +10,20 @@ pip install onehouse-cli
|
|
|
10
10
|
|
|
11
11
|
The SDK dependency (`onehouse-python-sdk[resources]`) is installed automatically.
|
|
12
12
|
|
|
13
|
+
## AI agent integration
|
|
14
|
+
|
|
15
|
+
The package bundles a skill that teaches AI coding agents how to use `oh`. Install it for your agent:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
oh install agent-skill --claude # ~/.claude/skills/onehouse-cli
|
|
19
|
+
oh install agent-skill --cursor # ~/.cursor/skills/onehouse-cli
|
|
20
|
+
oh install agent-skill --codex # ~/.agents/skills/onehouse-cli
|
|
21
|
+
oh install agent-skill --all # all of the above
|
|
22
|
+
oh install agent-skill --all --dry-run
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Re-run the command after `pip install --upgrade onehouse-cli` to refresh the skill. To read the skill without installing it, run `oh skill`.
|
|
26
|
+
|
|
13
27
|
## Setup
|
|
14
28
|
|
|
15
29
|
**Configure credentials:**
|
|
@@ -23,11 +37,12 @@ Fields collected:
|
|
|
23
37
|
- Profile name
|
|
24
38
|
- Account UID
|
|
25
39
|
- Project UID
|
|
26
|
-
- API Key / API Secret
|
|
40
|
+
- API Key / API Secret (blank if you use `oh login`)
|
|
27
41
|
- Project Region
|
|
28
42
|
- User ID
|
|
29
43
|
- Request ID (link UID)
|
|
30
|
-
- API Endpoint (default: `https://api.onehouse.ai
|
|
44
|
+
- API Endpoint (default: `https://api.onehouse.ai`)
|
|
45
|
+
- AI Gateway URL (optional; only `oh ai-gateway` reads it)
|
|
31
46
|
|
|
32
47
|
**Credentials are stored in:** `~/.onehouse/credentials`
|
|
33
48
|
|
|
@@ -43,6 +58,58 @@ export ONEHOUSE_LINK_UID=...
|
|
|
43
58
|
export ONEHOUSE_PROFILE=myprofile # select a profile from the credentials file
|
|
44
59
|
```
|
|
45
60
|
|
|
61
|
+
## Sign in instead of an API key (`oh login`)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
oh login # opens your browser; commands then run as you
|
|
65
|
+
oh logout # revokes the session and forgets it
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- Your organization must have OAuth sign-in for the API turned on. Ask your Onehouse contact.
|
|
69
|
+
- The profile still needs Account UID, Project UID, Region and Request ID (link UID). It does not need an API key, secret or User ID: `oh-configure` accepts them blank.
|
|
70
|
+
- While you are signed in, commands use your session instead of the profile's API key and secret. `oh logout` switches back.
|
|
71
|
+
- The session renews itself for 30 days. The refresh token is kept in your OS keychain. A machine with no keychain, such as headless Linux, keeps it in `~/.onehouse/sessions/` with mode `0600`. `ONEHOUSE_SESSION_STORE=file` forces the file.
|
|
72
|
+
- A machine with no browser uses a service principal's API key, or `oh login --port <n>` over an SSH forward.
|
|
73
|
+
- `oh login` signs in to the Onehouse API only, not to an AI Gateway; that is `oh ai-gateway login`.
|
|
74
|
+
|
|
75
|
+
## AI Gateway: Claude Code and Codex with no pasted token
|
|
76
|
+
|
|
77
|
+
`oh ai-gateway` keeps an AI Gateway token fresh for the whole session. Set the gateway once with `oh-configure` (AI Gateway URL) or pass `--gateway-url`. Everything here needs the VPN or your own network.
|
|
78
|
+
|
|
79
|
+
As a person:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
oh ai-gateway login # once; prints the one-time MCP setup for both clients
|
|
83
|
+
oh ai-gateway run -- claude # every session
|
|
84
|
+
oh ai-gateway run -- codex -m <model alias>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
As a service principal: use a profile with its API key and secret, skip `login`, and add the MCP entry `oh ai-gateway login` would print. In short:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
claude mcp add-json --scope user oh-ai-gateway \
|
|
91
|
+
'{"type":"http","url":"<gateway>/mcp","headersHelper":"oh ai-gateway token --format mcp-headers --gateway-url <gateway>"}'
|
|
92
|
+
oh ai-gateway run -- claude
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For Codex 0.148.0 or later, add to `~/.codex/config.toml`:
|
|
96
|
+
|
|
97
|
+
```toml
|
|
98
|
+
[mcp_servers.oh-ai-gateway]
|
|
99
|
+
url = "<gateway>/mcp"
|
|
100
|
+
http_headers_helper = "oh ai-gateway token --format mcp-headers --gateway-url <gateway>"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Model calls go through a local proxy that `run` starts on `127.0.0.1` and stops when the client exits. It sets a fresh `X-Onehouse-Authorization` on every request and retries once after a 401. The client gets the proxy's address and a per-run secret in its environment; nothing is copied by hand or written to disk.
|
|
104
|
+
- MCP tools use the `headersHelper` (Claude Code) or `http_headers_helper` (Codex). The client runs it on every connection and again after a 401.
|
|
105
|
+
- Codex runs `http_headers_helper` without your shell's `ONEHOUSE_*` variables. The helper `oh ai-gateway login` prints carries `--profile` and, as an `env` prefix (not on Windows), any `ONEHOUSE_CREDENTIALS_FILE` or `ONEHOUSE_SESSION_STORE` you set. Writing the entry by hand, do the same, and keep a service principal's key and secret in the profile rather than in `ONEHOUSE_API_KEY` and `ONEHOUSE_API_SECRET`.
|
|
106
|
+
- Codex sends only `/v1/responses`, which the gateway passes to the provider as is, so `-m` must name a model alias served by an OpenAI model with the Responses API, such as `gpt-5`. An alias served by Anthropic returns 404, and `gpt-4` fails: it rejects Codex's `web_search_preview` tool and its 8K context is too small.
|
|
107
|
+
- Add the MCP entry at user scope: a project or local-scope `headersHelper` does not run until you accept the folder trust prompt.
|
|
108
|
+
- `oh ai-gateway proxy` runs the proxy on its own for other clients and prints the two variables to set.
|
|
109
|
+
- `oh ai-gateway token` prints a token, and `--format mcp-headers` prints the JSON a headers helper returns.
|
|
110
|
+
- `oh ai-gateway logout` ends the sign-in; the profile's service principal, if any, applies again. Tokens already issued to a person stay valid until they expire, up to 7 days.
|
|
111
|
+
- `run` warns when `ANTHROPIC_API_KEY` is set: it displaces your subscription, and the gateway forwards it upstream as your own key.
|
|
112
|
+
|
|
46
113
|
## Global Options
|
|
47
114
|
|
|
48
115
|
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Onehouse CLI - A command-line tool for executing SQL commands against the Onehouse API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# For Python 3.8+
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
except ImportError:
|
|
9
|
+
# For Python 3.7
|
|
10
|
+
from importlib_metadata import version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("onehouse-cli")
|
|
14
|
+
except Exception:
|
|
15
|
+
# Fallback version if package is not installed
|
|
16
|
+
__version__ = "0.0.0.dev"
|
|
17
|
+
|
|
18
|
+
__all__ = ["cli_main", "configure_main"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Imported lazily (PEP 562) so that pulling in a leaf module does not drag in
|
|
22
|
+
# the whole command tree — and with it the SDK dependency. This keeps
|
|
23
|
+
# `oh-skill` readable on an install where onehouse-python-sdk is missing,
|
|
24
|
+
# which is precisely when someone needs the usage guide.
|
|
25
|
+
def __getattr__(name):
|
|
26
|
+
if name == "cli_main":
|
|
27
|
+
from .cli import main
|
|
28
|
+
return main
|
|
29
|
+
if name == "configure_main":
|
|
30
|
+
from .configure import main
|
|
31
|
+
return main
|
|
32
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""A local forwarding proxy that keeps an AI Gateway token fresh for model calls.
|
|
2
|
+
|
|
3
|
+
Claude Code reads ``ANTHROPIC_CUSTOM_HEADERS`` once per session, and the gateway's
|
|
4
|
+
``/`` route reads only ``X-Onehouse-Authorization``, so a long session outlives any
|
|
5
|
+
token set in the environment. Pointed at this proxy, a client needs no token at all:
|
|
6
|
+
the proxy sets a fresh one on every request and replays once after a 401.
|
|
7
|
+
|
|
8
|
+
Any local process can reach a loopback port, so a request must carry the per-run
|
|
9
|
+
secret, and the ``Host`` must be the proxy's own (no DNS rebinding from a browser).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hmac
|
|
15
|
+
import http.client
|
|
16
|
+
import http.server
|
|
17
|
+
import secrets
|
|
18
|
+
import ssl
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import urllib.parse
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from onehouse_python_sdk._oauth import default_ssl_context
|
|
25
|
+
|
|
26
|
+
#: Carries the per-run secret from the client to the proxy. Stripped before forwarding.
|
|
27
|
+
SECRET_HEADER = "X-Oh-Proxy-Secret"
|
|
28
|
+
GATEWAY_AUTH_HEADER = "X-Onehouse-Authorization"
|
|
29
|
+
|
|
30
|
+
_HOP_BY_HOP = {
|
|
31
|
+
"connection",
|
|
32
|
+
"keep-alive",
|
|
33
|
+
"proxy-authenticate",
|
|
34
|
+
"proxy-authorization",
|
|
35
|
+
"proxy-connection",
|
|
36
|
+
"te",
|
|
37
|
+
"trailer",
|
|
38
|
+
"trailers",
|
|
39
|
+
"transfer-encoding",
|
|
40
|
+
"upgrade",
|
|
41
|
+
}
|
|
42
|
+
_DROPPED_REQUEST_HEADERS = _HOP_BY_HOP | {
|
|
43
|
+
"host",
|
|
44
|
+
"content-length",
|
|
45
|
+
SECRET_HEADER.lower(),
|
|
46
|
+
GATEWAY_AUTH_HEADER.lower(),
|
|
47
|
+
}
|
|
48
|
+
_UPSTREAM_TIMEOUT_SECONDS = 600
|
|
49
|
+
_CHUNK = 8192
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def new_secret() -> str:
|
|
53
|
+
return secrets.token_urlsafe(32)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
57
|
+
protocol_version = "HTTP/1.1"
|
|
58
|
+
# Set per server by AiGatewayProxy.
|
|
59
|
+
proxy: "AiGatewayProxy"
|
|
60
|
+
|
|
61
|
+
def log_message(self, *_args) -> None:
|
|
62
|
+
"""The default access log would print request lines; tokens never pass through it,
|
|
63
|
+
but nothing here needs logging either."""
|
|
64
|
+
|
|
65
|
+
def do_CONNECT(self) -> None: # noqa: N802
|
|
66
|
+
self._refuse(405, "CONNECT is not supported")
|
|
67
|
+
|
|
68
|
+
def do_GET(self) -> None: # noqa: N802
|
|
69
|
+
self._forward()
|
|
70
|
+
|
|
71
|
+
do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = do_GET # noqa: N815
|
|
72
|
+
|
|
73
|
+
def _refuse(self, status: int, message: str) -> None:
|
|
74
|
+
body = message.encode("utf-8")
|
|
75
|
+
self.send_response(status)
|
|
76
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
77
|
+
self.send_header("Content-Length", str(len(body)))
|
|
78
|
+
self.send_header("Connection", "close")
|
|
79
|
+
self.end_headers()
|
|
80
|
+
self.wfile.write(body)
|
|
81
|
+
self.close_connection = True
|
|
82
|
+
|
|
83
|
+
def _read_body(self) -> Optional[bytes]:
|
|
84
|
+
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
|
85
|
+
chunks = []
|
|
86
|
+
while True:
|
|
87
|
+
size = int(self.rfile.readline().split(b";", 1)[0].strip() or b"0", 16)
|
|
88
|
+
if size == 0:
|
|
89
|
+
while self.rfile.readline() not in (b"\r\n", b"\n", b""):
|
|
90
|
+
pass
|
|
91
|
+
break
|
|
92
|
+
chunks.append(self.rfile.read(size))
|
|
93
|
+
self.rfile.readline()
|
|
94
|
+
return b"".join(chunks)
|
|
95
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
96
|
+
return self.rfile.read(length) if length else None
|
|
97
|
+
|
|
98
|
+
def _forward(self) -> None:
|
|
99
|
+
proxy = self.proxy
|
|
100
|
+
if self.headers.get("Host", "").lower() not in proxy.allowed_hosts:
|
|
101
|
+
self._refuse(403, "unexpected Host")
|
|
102
|
+
return
|
|
103
|
+
if not hmac.compare_digest(self.headers.get(SECRET_HEADER, ""), proxy.secret):
|
|
104
|
+
self._refuse(403, f"missing or wrong {SECRET_HEADER}")
|
|
105
|
+
return
|
|
106
|
+
# Origin-form only: an absolute-form target would let a caller pick the upstream.
|
|
107
|
+
if not self.path.startswith("/") or self.path.startswith("//"):
|
|
108
|
+
self._refuse(400, "absolute-form request targets are not supported")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Buffered so a request refused for an expired token can be replayed once.
|
|
112
|
+
body = self._read_body()
|
|
113
|
+
try:
|
|
114
|
+
upstream = proxy.send(self.command, self.path, self.headers, body, force=False)
|
|
115
|
+
if upstream.status == 401:
|
|
116
|
+
upstream.close()
|
|
117
|
+
upstream = proxy.send(self.command, self.path, self.headers, body, force=True)
|
|
118
|
+
except Exception as err: # noqa: BLE001 - surfaced to the client, never the token
|
|
119
|
+
self._refuse(502, f"could not reach the AI Gateway: {type(err).__name__}: {err}")
|
|
120
|
+
return
|
|
121
|
+
self._relay(upstream)
|
|
122
|
+
|
|
123
|
+
def _relay(self, upstream: "http.client.HTTPResponse") -> None:
|
|
124
|
+
self.send_response(upstream.status, upstream.reason)
|
|
125
|
+
length = upstream.getheader("Content-Length")
|
|
126
|
+
for name, value in upstream.getheaders():
|
|
127
|
+
if name.lower() in _HOP_BY_HOP or name.lower() == "content-length":
|
|
128
|
+
continue
|
|
129
|
+
self.send_header(name, value)
|
|
130
|
+
# http.client already undid any upstream chunking, so re-chunk when the length
|
|
131
|
+
# is unknown: that is what lets SSE stream through without buffering.
|
|
132
|
+
chunked = length is None and self.command != "HEAD"
|
|
133
|
+
if chunked:
|
|
134
|
+
self.send_header("Transfer-Encoding", "chunked")
|
|
135
|
+
elif length is not None:
|
|
136
|
+
self.send_header("Content-Length", length)
|
|
137
|
+
self.end_headers()
|
|
138
|
+
try:
|
|
139
|
+
while self.command != "HEAD":
|
|
140
|
+
data = upstream.read1(_CHUNK)
|
|
141
|
+
if not data:
|
|
142
|
+
break
|
|
143
|
+
if chunked:
|
|
144
|
+
self.wfile.write(f"{len(data):X}\r\n".encode("ascii") + data + b"\r\n")
|
|
145
|
+
else:
|
|
146
|
+
self.wfile.write(data)
|
|
147
|
+
self.wfile.flush()
|
|
148
|
+
if chunked:
|
|
149
|
+
self.wfile.write(b"0\r\n\r\n")
|
|
150
|
+
self.wfile.flush()
|
|
151
|
+
finally:
|
|
152
|
+
upstream.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _Server(http.server.ThreadingHTTPServer):
|
|
156
|
+
def handle_error(self, request, client_address) -> None:
|
|
157
|
+
# The proxy shares a terminal with the client it runs, so the default traceback
|
|
158
|
+
# lands in that client's UI. A client dropping a kept-alive connection is routine.
|
|
159
|
+
if isinstance(sys.exc_info()[1], ConnectionError):
|
|
160
|
+
return
|
|
161
|
+
super().handle_error(request, client_address)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AiGatewayProxy:
|
|
165
|
+
"""Forwards to one gateway only, setting ``X-Onehouse-Authorization`` from ``token_source``.
|
|
166
|
+
|
|
167
|
+
``Authorization`` and ``x-api-key`` pass through untouched: a subscription token
|
|
168
|
+
stays on the gateway's passthrough branch, and a client that sends none stays
|
|
169
|
+
mediated.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
gateway_url: str,
|
|
175
|
+
token_source,
|
|
176
|
+
*,
|
|
177
|
+
port: int = 0,
|
|
178
|
+
secret: Optional[str] = None,
|
|
179
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
180
|
+
connection_factory=None,
|
|
181
|
+
) -> None:
|
|
182
|
+
parts = urllib.parse.urlsplit(gateway_url)
|
|
183
|
+
if parts.scheme != "https" or not parts.netloc:
|
|
184
|
+
raise ValueError(f"AI Gateway URL must be https: {gateway_url!r}")
|
|
185
|
+
self._gateway_host = parts.netloc
|
|
186
|
+
self._gateway_base_path = parts.path.rstrip("/")
|
|
187
|
+
self._token_source = token_source
|
|
188
|
+
self._ssl_context = ssl_context or default_ssl_context()
|
|
189
|
+
# Tests swap in a plain-HTTP connection to a local upstream.
|
|
190
|
+
self._connection_factory = connection_factory or (
|
|
191
|
+
lambda host: http.client.HTTPSConnection(
|
|
192
|
+
host, timeout=_UPSTREAM_TIMEOUT_SECONDS, context=self._ssl_context
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
self.secret = secret or new_secret()
|
|
196
|
+
handler = type("_BoundHandler", (_Handler,), {"proxy": self})
|
|
197
|
+
self._server = _Server(("127.0.0.1", port), handler)
|
|
198
|
+
self._server.daemon_threads = True
|
|
199
|
+
self.port = self._server.server_address[1]
|
|
200
|
+
self.allowed_hosts = {f"127.0.0.1:{self.port}", f"localhost:{self.port}"}
|
|
201
|
+
self._thread: Optional[threading.Thread] = None
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def base_url(self) -> str:
|
|
205
|
+
return f"http://127.0.0.1:{self.port}"
|
|
206
|
+
|
|
207
|
+
def send(self, method: str, path: str, headers, body: Optional[bytes], *, force: bool):
|
|
208
|
+
token = self._token_source.get(force=force)
|
|
209
|
+
connection = self._connection_factory(self._gateway_host)
|
|
210
|
+
connection.putrequest(method, self._gateway_base_path + path, skip_accept_encoding=True)
|
|
211
|
+
for name, value in headers.items():
|
|
212
|
+
if name.lower() not in _DROPPED_REQUEST_HEADERS:
|
|
213
|
+
connection.putheader(name, value)
|
|
214
|
+
connection.putheader(GATEWAY_AUTH_HEADER, f"Bearer {token}")
|
|
215
|
+
if body is not None:
|
|
216
|
+
connection.putheader("Content-Length", str(len(body)))
|
|
217
|
+
connection.endheaders(body)
|
|
218
|
+
return connection.getresponse()
|
|
219
|
+
|
|
220
|
+
def start(self) -> None:
|
|
221
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
222
|
+
self._thread.start()
|
|
223
|
+
|
|
224
|
+
def serve_forever(self) -> None:
|
|
225
|
+
self._server.serve_forever()
|
|
226
|
+
|
|
227
|
+
def stop(self) -> None:
|
|
228
|
+
self._server.shutdown()
|
|
229
|
+
self._server.server_close()
|
|
@@ -5,11 +5,12 @@ Global options (--profile, --timeout, --output) are propagated to every
|
|
|
5
5
|
subcommand via the Click context object (ctx.obj).
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import sys
|
|
9
8
|
import click
|
|
10
9
|
|
|
11
10
|
from . import __version__
|
|
12
11
|
from .commands import (
|
|
12
|
+
ai_gateway,
|
|
13
|
+
auth,
|
|
13
14
|
cluster,
|
|
14
15
|
lake,
|
|
15
16
|
database,
|
|
@@ -22,10 +23,21 @@ from .commands import (
|
|
|
22
23
|
job,
|
|
23
24
|
service_principal,
|
|
24
25
|
api_token,
|
|
26
|
+
install,
|
|
27
|
+
skill as skill_cmd,
|
|
25
28
|
sql as sql_cmd,
|
|
26
29
|
)
|
|
27
30
|
|
|
28
31
|
|
|
32
|
+
_EPILOG = """\
|
|
33
|
+
Agents and new users: run `oh skill` for a complete usage guide covering
|
|
34
|
+
credential setup, the SQL each command emits, and destructive-operation safety.
|
|
35
|
+
|
|
36
|
+
Note: deletes execute immediately with no confirmation prompt. Use --show-sql
|
|
37
|
+
on any command to preview the statement without running it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
29
41
|
class CliContext:
|
|
30
42
|
def __init__(self, profile: str | None, timeout: int, output: str):
|
|
31
43
|
self.profile = profile
|
|
@@ -33,7 +45,10 @@ class CliContext:
|
|
|
33
45
|
self.output = output
|
|
34
46
|
|
|
35
47
|
|
|
36
|
-
@click.group(
|
|
48
|
+
@click.group(
|
|
49
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
50
|
+
epilog=_EPILOG,
|
|
51
|
+
)
|
|
37
52
|
@click.version_option(__version__, "-v", "--version")
|
|
38
53
|
@click.option(
|
|
39
54
|
"--profile", "-p",
|
|
@@ -75,10 +90,17 @@ cli.add_command(job.job)
|
|
|
75
90
|
cli.add_command(service_principal.service_principal, name="service-principal")
|
|
76
91
|
cli.add_command(api_token.api_token, name="api-token")
|
|
77
92
|
cli.add_command(sql_cmd.sql)
|
|
93
|
+
cli.add_command(skill_cmd.skill)
|
|
94
|
+
cli.add_command(install.install)
|
|
95
|
+
cli.add_command(auth.login)
|
|
96
|
+
cli.add_command(auth.logout)
|
|
97
|
+
cli.add_command(ai_gateway.ai_gateway_group)
|
|
78
98
|
|
|
79
99
|
|
|
80
100
|
def main() -> None:
|
|
81
|
-
|
|
101
|
+
# Standalone mode turns Click errors into a one-line `Error: ...` message
|
|
102
|
+
# and the conventional exit code (1 = failure, 2 = usage error).
|
|
103
|
+
cli()
|
|
82
104
|
|
|
83
105
|
|
|
84
106
|
if __name__ == "__main__":
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
9
|
try:
|
|
10
|
+
from onehouse_python_sdk.resources import oauth
|
|
10
11
|
from onehouse_python_sdk.resources.client import OnehouseResources
|
|
11
12
|
from onehouse_python_sdk.resources.exceptions import AuthError, ResourcesError
|
|
12
13
|
except ImportError as exc:
|
|
@@ -17,9 +18,16 @@ except ImportError as exc:
|
|
|
17
18
|
)
|
|
18
19
|
sys.exit(1)
|
|
19
20
|
|
|
21
|
+
from .profile import api_endpoint
|
|
22
|
+
from .session_store import api_session_store
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
def make_client(profile: str | None, timeout: int) -> OnehouseResources:
|
|
22
|
-
"""Return an OnehouseResources configured from profile + env vars.
|
|
26
|
+
"""Return an OnehouseResources configured from profile + env vars.
|
|
27
|
+
|
|
28
|
+
After `oh login` the client sends the signed-in user's token, and the profile's
|
|
29
|
+
API key and secret are not used. Without a session it uses them, as before.
|
|
30
|
+
"""
|
|
23
31
|
kwargs: dict = {
|
|
24
32
|
"default_execute_timeout": float(timeout * 60),
|
|
25
33
|
}
|
|
@@ -32,6 +40,10 @@ def make_client(profile: str | None, timeout: int) -> OnehouseResources:
|
|
|
32
40
|
kwargs["profile"] = profile
|
|
33
41
|
|
|
34
42
|
try:
|
|
43
|
+
endpoint = api_endpoint(profile)
|
|
44
|
+
store = api_session_store(oauth.issuer_for(endpoint))
|
|
45
|
+
if store.get() is not None:
|
|
46
|
+
kwargs["token_source"] = oauth.FirebaseRefreshTokenSource(endpoint, store)
|
|
35
47
|
return OnehouseResources(**kwargs)
|
|
36
48
|
except AuthError as exc:
|
|
37
49
|
raise click.ClickException(f"Authentication error: {exc}") from exc
|