knoxcall 0.0.1 → 1.0.0
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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- metadata +70 -9
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
# Bootstrap credential types. The +type+ discriminator defaults per class,
|
|
3
|
+
# so callers never need to spell it out. Secret fields are excluded from
|
|
4
|
+
# #inspect so a logged client or captured exception context never prints
|
|
5
|
+
# the secret (Object#to_s is already safe — it shows only class + object id).
|
|
6
|
+
|
|
7
|
+
class AccessToken
|
|
8
|
+
attr_reader :type
|
|
9
|
+
|
|
10
|
+
def initialize(access_token:)
|
|
11
|
+
@access_token = access_token
|
|
12
|
+
@type = "access_token"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def access_token = @access_token
|
|
16
|
+
|
|
17
|
+
def inspect = "#<KnoxCall::AccessToken access_token=[REDACTED]>"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class OIDCTokenExchange
|
|
21
|
+
attr_reader :issuer, :type
|
|
22
|
+
|
|
23
|
+
def initialize(subject_token:, issuer:)
|
|
24
|
+
@subject_token = subject_token
|
|
25
|
+
@issuer = issuer
|
|
26
|
+
@type = "oidc_token_exchange"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subject_token = @subject_token
|
|
30
|
+
|
|
31
|
+
def inspect = "#<KnoxCall::OIDCTokenExchange issuer=#{@issuer.inspect} subject_token=[REDACTED]>"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class ClientCredentials
|
|
35
|
+
attr_reader :client_id, :type
|
|
36
|
+
|
|
37
|
+
def initialize(client_id:, client_secret:)
|
|
38
|
+
@client_id = client_id
|
|
39
|
+
@client_secret = client_secret
|
|
40
|
+
@type = "client_credentials"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def client_secret = @client_secret
|
|
44
|
+
|
|
45
|
+
def inspect = "#<KnoxCall::ClientCredentials client_id=#{@client_id.inspect} client_secret=[REDACTED]>"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Credentials file written by `knoxcall login` (PARITY §2).
|
|
49
|
+
#
|
|
50
|
+
# Holds no secrets itself — tokens are read from the file (path/profile
|
|
51
|
+
# resolved from KNOXCALL_CREDENTIALS_FILE / KNOXCALL_PROFILE when not
|
|
52
|
+
# given) at token-fetch time, so #inspect stays safe by construction.
|
|
53
|
+
class StoredCredentials
|
|
54
|
+
attr_reader :path, :profile, :type
|
|
55
|
+
|
|
56
|
+
def initialize(path: nil, profile: nil)
|
|
57
|
+
@path = path
|
|
58
|
+
@profile = profile
|
|
59
|
+
@type = "stored_credentials"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inspect = "#<KnoxCall::StoredCredentials path=#{@path.inspect} profile=#{@profile.inspect}>"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Deprecated aliases — the pre-release *Bootstrap names used by the other
|
|
66
|
+
# KnoxCall SDKs. Remove before 2.0.
|
|
67
|
+
AccessTokenBootstrap = AccessToken
|
|
68
|
+
OidcTokenExchangeBootstrap = OIDCTokenExchange
|
|
69
|
+
ClientCredentialsBootstrap = ClientCredentials
|
|
70
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
# A route with bound call defaults — see Client#route.
|
|
3
|
+
#
|
|
4
|
+
# printnode = client.route("3f1e2c9a-...", environment: "production")
|
|
5
|
+
# computers = JSON.parse(printnode.get("/computers").body)
|
|
6
|
+
# printnode.post("/printjobs", body: payload)
|
|
7
|
+
#
|
|
8
|
+
# Holds only the client reference, route id, and defaults (never a token
|
|
9
|
+
# or any pipeline state), so retries and 401 re-mint behave exactly as on
|
|
10
|
+
# Client#call. Per-call values win over bound defaults; headers merge
|
|
11
|
+
# per-key with per-call winning. A nil per-call value inherits the bound
|
|
12
|
+
# default — there is no "explicitly clear" mechanism; construct another
|
|
13
|
+
# handle instead.
|
|
14
|
+
class BoundRoute
|
|
15
|
+
def initialize(client, route, environment: nil, headers: {}, timeout: nil)
|
|
16
|
+
@client = client
|
|
17
|
+
@route = route
|
|
18
|
+
@environment = environment
|
|
19
|
+
@headers = (headers || {}).dup.freeze
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def request(method, path = "/", body: nil, headers: {}, environment: nil, query: nil, timeout: nil)
|
|
25
|
+
@client.call(
|
|
26
|
+
@route,
|
|
27
|
+
method: method,
|
|
28
|
+
path: path,
|
|
29
|
+
body: body,
|
|
30
|
+
headers: @headers.merge(headers || {}),
|
|
31
|
+
environment: environment.nil? ? @environment : environment,
|
|
32
|
+
query: query,
|
|
33
|
+
timeout: timeout.nil? ? @timeout : timeout
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get(path = "/", **kw) = request("GET", path, **kw)
|
|
38
|
+
def post(path = "/", **kw) = request("POST", path, **kw)
|
|
39
|
+
def put(path = "/", **kw) = request("PUT", path, **kw)
|
|
40
|
+
def patch(path = "/", **kw) = request("PATCH", path, **kw)
|
|
41
|
+
def delete(path = "/", **kw) = request("DELETE", path, **kw)
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<KnoxCall::BoundRoute route=#{@route.inspect} environment=#{@environment.inspect}>"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module CLI
|
|
3
|
+
# `knoxcall ai exchange` — RFC 8693 workload federation from a terminal.
|
|
4
|
+
#
|
|
5
|
+
# Mirrors knoxcall-python's knoxcall/cli/ai.py (the PARITY §13 reference):
|
|
6
|
+
# same flags, same messages, same exit codes.
|
|
7
|
+
#
|
|
8
|
+
# The one KnoxCall command that needs no `knoxcall login` and no KnoxCall
|
|
9
|
+
# credential at all: the CI workload's own OIDC id_token IS the credential,
|
|
10
|
+
# and the server verifies it against the issuer's published JWKS.
|
|
11
|
+
#
|
|
12
|
+
# export KC_TOKEN="$(knoxcall ai exchange --tenant acme)"
|
|
13
|
+
#
|
|
14
|
+
# Two rules this command exists to enforce, because both are easy to get
|
|
15
|
+
# wrong in a CI script and neither fails in a way that names itself:
|
|
16
|
+
#
|
|
17
|
+
# 1. The subject token is read from the environment, NEVER a flag. An argv
|
|
18
|
+
# value lands in shell history, in +ps+ output, and in the CI log line
|
|
19
|
+
# that echoes the command. Same rule +knoxcall init+ applies to
|
|
20
|
+
# KNOXCALL_WRAP_SECRET.
|
|
21
|
+
# 2. The host is the tenant data plane, and there is no default. On
|
|
22
|
+
# api.knoxcall.com this endpoint answers 401, which reads as "my CI
|
|
23
|
+
# token was rejected" and sends people hunting through their issuer's
|
|
24
|
+
# JWKS.
|
|
25
|
+
#
|
|
26
|
+
# Only the token goes to stdout, so <tt>$(...)</tt> captures exactly the
|
|
27
|
+
# token.
|
|
28
|
+
module Ai
|
|
29
|
+
# The subject token is read from here, never from argv. See rule 1 above.
|
|
30
|
+
SUBJECT_TOKEN_ENV = "KNOXCALL_SUBJECT_TOKEN".freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def run(options)
|
|
35
|
+
subject_token = (ENV[SUBJECT_TOKEN_ENV] || "").strip
|
|
36
|
+
if subject_token.empty?
|
|
37
|
+
raise Error,
|
|
38
|
+
"#{SUBJECT_TOKEN_ENV} is not set — put your CI provider's OIDC id_token there " \
|
|
39
|
+
"(a flag would land in shell history, ps output and the CI log). GitHub Actions: " \
|
|
40
|
+
"request one with `id-token: write` and the ACTIONS_ID_TOKEN_REQUEST_URL endpoint, " \
|
|
41
|
+
'audience "knoxcall:gateway".'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
tenant = options[:tenant]
|
|
45
|
+
base_url = options[:base_url]
|
|
46
|
+
if (tenant.nil? || tenant.empty?) && (base_url.nil? || base_url.empty?)
|
|
47
|
+
raise Error,
|
|
48
|
+
"one of --tenant or --base-url is required: POST /v1/oauth/token is served only on " \
|
|
49
|
+
"the tenant data-plane host (https://{tenant}.knoxcall.com). Pointing it at " \
|
|
50
|
+
"api.knoxcall.com answers 401, which reads like a rejected subject_token but means " \
|
|
51
|
+
"the endpoint is not there."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
kwargs = {
|
|
55
|
+
subject_token: subject_token,
|
|
56
|
+
tenant: tenant,
|
|
57
|
+
sandbox: options[:sandbox] == true,
|
|
58
|
+
base_url: base_url
|
|
59
|
+
}
|
|
60
|
+
# `resource` is only passed when the flag was GIVEN: the SDK
|
|
61
|
+
# distinguishes nil from an empty string, and an empty one is a server
|
|
62
|
+
# refusal rather than "no resource".
|
|
63
|
+
kwargs[:resource] = options[:resource] if options.key?(:resource)
|
|
64
|
+
kwargs[:audience] = options[:audience] if options[:audience]
|
|
65
|
+
|
|
66
|
+
result = KnoxCall.exchange_token(**kwargs)
|
|
67
|
+
token = result["access_token"]
|
|
68
|
+
raise Error, "the exchange returned no access_token" if token.nil? || token.empty?
|
|
69
|
+
|
|
70
|
+
# stdout: the token, nothing else. stderr: everything a human wants.
|
|
71
|
+
puts token
|
|
72
|
+
kind = options.key?(:resource) ? "tool (MCP, resource-bound)" : "agent"
|
|
73
|
+
ttl = result["expires_in"].is_a?(Integer) ? ", valid #{result['expires_in']}s" : ""
|
|
74
|
+
warn "exchanged for a #{kind} token#{ttl}"
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module CLI
|
|
3
|
+
# `knoxcall ai` — the AI-gateway CONTROL plane from a terminal (AIGW-162).
|
|
4
|
+
#
|
|
5
|
+
# Mirrors the Node reference (sdk/knoxcall-node/src/cli/ai-control.ts):
|
|
6
|
+
# same flags, same messages, same stdout/stderr split, same exit codes.
|
|
7
|
+
#
|
|
8
|
+
# `ai exchange` (ai.rb) is the data-plane door: it needs no login, because
|
|
9
|
+
# the CI workload's OIDC token IS the credential. Everything here is the
|
|
10
|
+
# opposite — it acts as the signed-in tenant, through the same
|
|
11
|
+
# ~/.knoxcall/credentials.json profile `login` writes and `whoami` reads.
|
|
12
|
+
#
|
|
13
|
+
# WHY THIS EXISTS. Until now the five SDK CLIs shipped exactly one `ai`
|
|
14
|
+
# sub-command, `exchange`. A capable `knoxcall ai gateways|agents|mint|usage`
|
|
15
|
+
# lived in a standalone `cli/` package that was never published, never
|
|
16
|
+
# tested, never in CI and not in the workspaces — and it could not create a
|
|
17
|
+
# secret, a gateway or an agent, so it could not get you to a first call
|
|
18
|
+
# either. So there was no CLI golden path at all: the only way from "I have
|
|
19
|
+
# an API key" to "my app is calling an LLM through KnoxCall" was the browser
|
|
20
|
+
# or hand-written HTTP.
|
|
21
|
+
#
|
|
22
|
+
# The golden path these commands exist to make true, from a tenant with
|
|
23
|
+
# nothing in it:
|
|
24
|
+
#
|
|
25
|
+
# export ANTHROPIC_API_KEY=sk-ant-...
|
|
26
|
+
# knoxcall ai create-agent --name copilot --slug copilot \
|
|
27
|
+
# --provider anthropic --secret-from-env ANTHROPIC_API_KEY
|
|
28
|
+
# knoxcall ai mint --agent <id>
|
|
29
|
+
# curl "$AGENT_URL/v1/messages" -H "Authorization: Bearer $TOKEN" ...
|
|
30
|
+
#
|
|
31
|
+
# Two commands, then a real streamed call. +create-agent+ prints the agent
|
|
32
|
+
# id, the +agent_url+ and the exact next command, so the path is
|
|
33
|
+
# discoverable without re-reading the docs.
|
|
34
|
+
#
|
|
35
|
+
# THREE RULES, each one a bug this shape invites:
|
|
36
|
+
#
|
|
37
|
+
# 1. A PROVIDER KEY IS NEVER AN ARGV VALUE. +--secret-from-env NAME+ names
|
|
38
|
+
# the environment variable to read; there is deliberately no
|
|
39
|
+
# +--secret-value+. An argv value lands in shell history, in +ps+
|
|
40
|
+
# output and in the CI log line that echoes the command. Same rule
|
|
41
|
+
# +ai exchange+ applies to KNOXCALL_SUBJECT_TOKEN and +init+ to
|
|
42
|
+
# KNOXCALL_WRAP_SECRET.
|
|
43
|
+
#
|
|
44
|
+
# 2. NO POSITIONAL ARGUMENTS. Four of the five SDK CLIs hand-roll their
|
|
45
|
+
# parser and reject positionals outright; only python gets them free
|
|
46
|
+
# from argparse. Ids are flags (+--gateway+, +--agent+) so the surface
|
|
47
|
+
# is the same in all five rather than "the same except in Ruby".
|
|
48
|
+
#
|
|
49
|
+
# 3. AN AGENT WITHOUT AN UPSTREAM IS REFUSED HERE, not at its first call.
|
|
50
|
+
# The API accepts create_agent with no provider/upstream_secret_id and
|
|
51
|
+
# stores an agent whose first data-plane request 502s (AIGW-161). A
|
|
52
|
+
# command whose entire purpose is "get me to a working call" must not
|
|
53
|
+
# be able to produce that, so +--provider+ and one of +--secret+ /
|
|
54
|
+
# +--secret-from-env+ are required together.
|
|
55
|
+
#
|
|
56
|
+
# No HTTP lives here: every call goes through the typed SDK resources
|
|
57
|
+
# (client.ai_gateway, client.secrets).
|
|
58
|
+
module AiControl
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# A client acting as the signed-in tenant, or a refusal telling them to
|
|
62
|
+
# log in. Obtained exactly the way `whoami` and `init` obtain theirs.
|
|
63
|
+
def client_for(options)
|
|
64
|
+
path = CredentialsFile.resolve_path
|
|
65
|
+
profile = CredentialsFile.resolve_profile(options[:profile])
|
|
66
|
+
if CredentialsFile.read_profile(path, profile).nil?
|
|
67
|
+
raise Error, "not logged in (profile '#{profile}') — run `knoxcall login`"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
client_opts = { bootstrap: StoredCredentials.new(path: path, profile: profile) }
|
|
71
|
+
client_opts[:base_url] = options[:base_url] if presence(options[:base_url])
|
|
72
|
+
client_opts[:sandbox] = true if options[:sandbox]
|
|
73
|
+
Client.new(**client_opts)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def presence(value) = CredentialsFile.presence(value)
|
|
77
|
+
|
|
78
|
+
def required(value, flag)
|
|
79
|
+
found = presence(value)
|
|
80
|
+
raise Error, "#{flag} is required" if found.nil?
|
|
81
|
+
|
|
82
|
+
found
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# -- knoxcall ai gateways -------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def gateways(options)
|
|
88
|
+
client = client_for(options)
|
|
89
|
+
rows = client.ai_gateway.list_gateways(per_page: 100)["data"] || []
|
|
90
|
+
if rows.empty?
|
|
91
|
+
warn "No AI gateways. `knoxcall ai create-agent` will create one for you."
|
|
92
|
+
return 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
rows.each { |g| puts "#{g['id']} #{g['slug']} #{g['name']}" }
|
|
96
|
+
0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# -- knoxcall ai agents --gateway ID --------------------------------------
|
|
100
|
+
|
|
101
|
+
def agents(options)
|
|
102
|
+
gateway_id = required(options[:gateway], "--gateway")
|
|
103
|
+
client = client_for(options)
|
|
104
|
+
rows = client.ai_gateway.list_agents(gateway_id, per_page: 100)["data"] || []
|
|
105
|
+
if rows.empty?
|
|
106
|
+
warn "No agents in that gateway."
|
|
107
|
+
return 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# agent_url is on every projection since AIGW-161, so a list is enough
|
|
111
|
+
# to point an SDK at an existing agent — no follow-up GET.
|
|
112
|
+
rows.each { |a| puts "#{a['id']} #{a['slug']} #{a['agent_url']}" }
|
|
113
|
+
0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# -- knoxcall ai create-agent ---------------------------------------------
|
|
117
|
+
|
|
118
|
+
def create_agent(options)
|
|
119
|
+
slug = required(options[:slug], "--slug")
|
|
120
|
+
# Rule 3: refuse here rather than let the API store an agent with no
|
|
121
|
+
# upstream whose first data-plane call 502s.
|
|
122
|
+
provider = required(options[:provider], "--provider")
|
|
123
|
+
if presence(options[:secret]).nil? && presence(options[:secret_from_env]).nil?
|
|
124
|
+
raise Error,
|
|
125
|
+
"one of --secret or --secret-from-env is required: an agent created without an " \
|
|
126
|
+
"upstream credential is accepted by the API and 502s on its first call."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
client = client_for(options)
|
|
130
|
+
gateway_id = resolve_gateway(client, options[:gateway])
|
|
131
|
+
secret_id = resolve_secret(client, options)
|
|
132
|
+
|
|
133
|
+
body = {
|
|
134
|
+
name: presence(options[:name]) || slug,
|
|
135
|
+
slug: slug,
|
|
136
|
+
provider: provider,
|
|
137
|
+
upstream_secret_id: secret_id
|
|
138
|
+
}
|
|
139
|
+
body[:upstream] = options[:upstream] if presence(options[:upstream])
|
|
140
|
+
body[:default_model] = options[:model] if presence(options[:model])
|
|
141
|
+
agent = client.ai_gateway.create_agent(gateway_id, **body)
|
|
142
|
+
|
|
143
|
+
# stdout: the agent id, so $(...) captures exactly that. Everything a
|
|
144
|
+
# human needs next goes to stderr, including the command that follows.
|
|
145
|
+
puts agent["id"]
|
|
146
|
+
warn "\n agent: #{agent['slug']} (#{agent['id']})"
|
|
147
|
+
warn " gateway: #{gateway_id}"
|
|
148
|
+
warn " provider: #{provider}"
|
|
149
|
+
warn " base_url: #{agent['agent_url']}" if presence(agent["agent_url"])
|
|
150
|
+
warn "\n Next: knoxcall ai mint --agent #{agent['id']}"
|
|
151
|
+
0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Resolve the gateway to create under.
|
|
155
|
+
#
|
|
156
|
+
# +--gateway+ takes an id OR a slug. With no +--gateway+: use the tenant's
|
|
157
|
+
# only gateway, or create one when they have none — that is what makes the
|
|
158
|
+
# command work on a fresh tenant, which is the whole point. With SEVERAL
|
|
159
|
+
# and no flag it refuses and lists them rather than picking: "whichever
|
|
160
|
+
# sorts first" is how the quickstart wizard silently landed a second agent
|
|
161
|
+
# in the wrong gateway.
|
|
162
|
+
def resolve_gateway(client, wanted)
|
|
163
|
+
rows = client.ai_gateway.list_gateways(per_page: 100)["data"] || []
|
|
164
|
+
wanted = presence(wanted)
|
|
165
|
+
if wanted
|
|
166
|
+
hit = rows.find { |g| g["id"] == wanted || g["slug"] == wanted }
|
|
167
|
+
raise Error, "no gateway '#{wanted}' — this tenant has: #{gateway_list(rows)}" if hit.nil?
|
|
168
|
+
|
|
169
|
+
return hit["id"]
|
|
170
|
+
end
|
|
171
|
+
return rows.first["id"] if rows.length == 1
|
|
172
|
+
|
|
173
|
+
if rows.empty?
|
|
174
|
+
created = client.ai_gateway.create_gateway(name: "Default", slug: "default")
|
|
175
|
+
warn "created gateway #{created['slug']} (#{created['id']})"
|
|
176
|
+
return created["id"]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raise Error,
|
|
180
|
+
"--gateway is required: this tenant has #{rows.length} gateways " \
|
|
181
|
+
"(#{gateway_list(rows)}). Picking one for you would put the agent somewhere " \
|
|
182
|
+
"you did not choose."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def gateway_list(rows)
|
|
186
|
+
listed = rows.map { |g| "#{g['slug']} (#{g['id']})" }.join(", ")
|
|
187
|
+
listed.empty? ? "none" : listed
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resolve the upstream secret, escrowing one from the environment if asked.
|
|
191
|
+
#
|
|
192
|
+
# The key is read from ENV[NAME], never from a flag — see rule 1.
|
|
193
|
+
# Re-running with the same +--secret-from-env+ reuses the existing secret
|
|
194
|
+
# by name rather than creating a second copy of the same credential.
|
|
195
|
+
def resolve_secret(client, options)
|
|
196
|
+
secret = presence(options[:secret])
|
|
197
|
+
return secret if secret
|
|
198
|
+
|
|
199
|
+
env_name = required(options[:secret_from_env], "--secret or --secret-from-env")
|
|
200
|
+
value = (ENV[env_name] || "").strip
|
|
201
|
+
if value.empty?
|
|
202
|
+
raise Error,
|
|
203
|
+
"#{env_name} is not set — put your provider key there. There is deliberately no " \
|
|
204
|
+
"--secret-value flag: an argv value lands in shell history, ps output and the CI log."
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
name = "ai-gateway-#{presence(options[:slug]) || 'agent'}-key"
|
|
208
|
+
hit = (client.secrets.list(per_page: 100)["data"] || []).find { |s| s["name"] == name }
|
|
209
|
+
if hit
|
|
210
|
+
warn "reusing secret '#{name}' (#{hit['id']})"
|
|
211
|
+
return hit["id"]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
created = client.secrets.create(name: name, value: value)
|
|
215
|
+
warn "escrowed secret '#{name}' (#{created['id']}) — the key is now in KnoxCall custody"
|
|
216
|
+
created["id"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# -- knoxcall ai mint --agent ID ------------------------------------------
|
|
220
|
+
|
|
221
|
+
def mint(options)
|
|
222
|
+
agent_id = required(options[:agent], "--agent")
|
|
223
|
+
client = client_for(options)
|
|
224
|
+
body = {}
|
|
225
|
+
body[:kind] = options[:kind] if presence(options[:kind])
|
|
226
|
+
body[:name] = options[:name] if presence(options[:name])
|
|
227
|
+
minted = client.ai_gateway.mint_token(agent_id, **body)
|
|
228
|
+
|
|
229
|
+
# The plaintext is returned ONCE. stdout carries only the token so
|
|
230
|
+
# `> token.txt` captures the token and nothing else; the metadata and
|
|
231
|
+
# the warning go to stderr.
|
|
232
|
+
puts minted["token"]
|
|
233
|
+
warn "\n id: #{minted['id']}"
|
|
234
|
+
warn " kind: #{minted['kind']}"
|
|
235
|
+
warn " prefix: #{minted['prefix']}"
|
|
236
|
+
warn " dpop: #{minted['dpop_required']}"
|
|
237
|
+
warn " expires: #{minted['expires_at'] || 'never'}"
|
|
238
|
+
warn "\n Save this token now — it will not be shown again."
|
|
239
|
+
0
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# -- knoxcall ai usage ----------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def usage(options)
|
|
245
|
+
client = client_for(options)
|
|
246
|
+
agent_id = presence(options[:agent])
|
|
247
|
+
params = { period: presence(options[:period]) || "30d" }
|
|
248
|
+
params[:agent_id] = agent_id if agent_id
|
|
249
|
+
rollup = client.ai_gateway.usage(**params)
|
|
250
|
+
totals = rollup["totals"] || {}
|
|
251
|
+
|
|
252
|
+
puts "Usage — last #{rollup['period_days']} days#{agent_id ? " (agent #{agent_id})" : ''}"
|
|
253
|
+
puts " requests: #{totals['requests']}"
|
|
254
|
+
puts " input tokens: #{totals['input_tokens']}"
|
|
255
|
+
puts " output tokens: #{totals['output_tokens']}"
|
|
256
|
+
puts format(" cost (USD): %.4f", totals["cost_usd"].to_f)
|
|
257
|
+
puts " unpriced: #{totals['unpriced_requests']}"
|
|
258
|
+
|
|
259
|
+
by_model = rollup["by_model"] || []
|
|
260
|
+
if by_model.empty?
|
|
261
|
+
puts "\nNo usage in this period."
|
|
262
|
+
return 0
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
puts "\nBy model:"
|
|
266
|
+
by_model.each do |m|
|
|
267
|
+
puts format(" %s/%s %s req in %s out %s $%.4f",
|
|
268
|
+
m["provider"], m["model"], m["requests"],
|
|
269
|
+
m["input_tokens"], m["output_tokens"], m["cost_usd"].to_f)
|
|
270
|
+
end
|
|
271
|
+
0
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module KnoxCall
|
|
7
|
+
module CLI
|
|
8
|
+
# Reserved alias accepted by /oauth/authorize and the device endpoints; the
|
|
9
|
+
# server lazily provisions the tenant's real CLI client and returns its id
|
|
10
|
+
# as the +client_id+ extension member on the token response.
|
|
11
|
+
CLI_CLIENT_ID = "knoxcall-cli"
|
|
12
|
+
|
|
13
|
+
# Expected CLI failure — printed as a one-line message, never a backtrace.
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Shared CLI plumbing — token-endpoint POSTs, profile persistence.
|
|
17
|
+
module Common
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# POST a urlencoded form; return [status, parsed-JSON-or-empty-hash].
|
|
21
|
+
#
|
|
22
|
+
# Connection failures raise CLI::Error with a human message. HTTP error
|
|
23
|
+
# statuses are returned, not raised — device polling needs the error codes.
|
|
24
|
+
def post_form(url, form, timeout: 30)
|
|
25
|
+
uri = URI.parse(url)
|
|
26
|
+
req = Net::HTTP::Post.new(uri)
|
|
27
|
+
req["Content-Type"] = "application/x-www-form-urlencoded"
|
|
28
|
+
req["Accept"] = "application/json"
|
|
29
|
+
req["User-Agent"] = SDK_VERSION
|
|
30
|
+
req.body = URI.encode_www_form(form)
|
|
31
|
+
|
|
32
|
+
resp = begin
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = uri.scheme == "https"
|
|
35
|
+
http.open_timeout = timeout
|
|
36
|
+
http.read_timeout = timeout
|
|
37
|
+
http.start { |h| h.request(req) }
|
|
38
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError,
|
|
39
|
+
EOFError, SocketError, SystemCallError, IOError => e
|
|
40
|
+
raise Error, "could not reach #{url}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
body = begin
|
|
44
|
+
JSON.parse(resp.body.to_s)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
[resp.code.to_i, body.is_a?(Hash) ? body : {}]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def token_error_message(status, body)
|
|
52
|
+
detail = CredentialsFile.presence(body["error_description"]) ||
|
|
53
|
+
CredentialsFile.presence(body["error"]) || "HTTP #{status}"
|
|
54
|
+
"sign-in failed: #{detail}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Store a successful token response as a credentials-file profile.
|
|
58
|
+
#
|
|
59
|
+
# Persists the +tenant+ and +client_id+ extension members — refreshes
|
|
60
|
+
# must use the REAL per-tenant client id, not the +knoxcall-cli+ alias.
|
|
61
|
+
# The write happens UNDER the cross-process file lock so a login racing
|
|
62
|
+
# a concurrent refresh never loses a rotation.
|
|
63
|
+
def persist_login(path:, profile:, base_url:, token_body:, fallback_tenant: nil)
|
|
64
|
+
expires_in = begin
|
|
65
|
+
Float(token_body["expires_in"] || 3600)
|
|
66
|
+
rescue ArgumentError, TypeError
|
|
67
|
+
3600.0
|
|
68
|
+
end
|
|
69
|
+
record = {
|
|
70
|
+
"tenant" => CredentialsFile.presence(token_body["tenant"]) || fallback_tenant,
|
|
71
|
+
"base_url" => base_url,
|
|
72
|
+
"client_id" => CredentialsFile.presence(token_body["client_id"]) || CLI_CLIENT_ID,
|
|
73
|
+
"refresh_token" => token_body["refresh_token"],
|
|
74
|
+
"access_token" => token_body["access_token"],
|
|
75
|
+
"access_token_expires_at" => CredentialsFile.format_expiry(Time.now + expires_in),
|
|
76
|
+
"scope" => token_body["scope"] || ""
|
|
77
|
+
}
|
|
78
|
+
# The lock file lives next to the target — on a first-ever login the
|
|
79
|
+
# directory does not exist yet, so create it before acquiring.
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
81
|
+
CredentialsFile::Lock.new(path).with_lock do
|
|
82
|
+
CredentialsFile.write_profile(path, profile, record)
|
|
83
|
+
end
|
|
84
|
+
record
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Default management base: KNOXCALL_BASE_URL env > (sandbox|production)
|
|
88
|
+
# host. An explicit --base-url beats both (handled by the caller).
|
|
89
|
+
def default_base_url(sandbox)
|
|
90
|
+
env = ENV["KNOXCALL_BASE_URL"]
|
|
91
|
+
return env if env && !env.empty?
|
|
92
|
+
sandbox ? "https://sandbox.#{DEFAULT_CLOUD_HOST}" : DEFAULT_API_BASE
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module CLI
|
|
3
|
+
# `knoxcall init` — get started wrapping a provider SDK through KnoxCall
|
|
4
|
+
# (sdk-wrapping #17.4; PARITY §13). Mirrors the Node reference (src/cli/init.ts).
|
|
5
|
+
#
|
|
6
|
+
# SAFE BY DESIGN — this does NOT provision a tenant. It works against the
|
|
7
|
+
# tenant you are already signed in to (`knoxcall login`). Two modes:
|
|
8
|
+
#
|
|
9
|
+
# knoxcall init
|
|
10
|
+
# Scaffold mode: confirm who you're signed in as and print a two-step
|
|
11
|
+
# wrap quickstart. No writes.
|
|
12
|
+
#
|
|
13
|
+
# knoxcall init --provider stripe --secret-name wrap-stripe --host api.stripe.com
|
|
14
|
+
# One-shot escrow: move a provider key into KnoxCall custody and print
|
|
15
|
+
# the gateway base_url to point your SDK at. The KEY is read from the
|
|
16
|
+
# KNOXCALL_WRAP_SECRET env var (never a flag) so it stays out of your
|
|
17
|
+
# shell history/argv. Escrow is idempotent-ish server-side (409 on a
|
|
18
|
+
# duplicate name).
|
|
19
|
+
#
|
|
20
|
+
# Tenant provisioning + a fully headless one-shot flow are a deliberate
|
|
21
|
+
# follow-up — a CLI that mints tenants is a bigger, riskier surface.
|
|
22
|
+
module Init
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def run(options)
|
|
26
|
+
# Auth: reuse the stored login. Never provision.
|
|
27
|
+
path = CredentialsFile.resolve_path
|
|
28
|
+
profile = CredentialsFile.resolve_profile(options[:profile])
|
|
29
|
+
if CredentialsFile.read_profile(path, profile).nil?
|
|
30
|
+
raise Error, "not logged in (profile '#{profile}') — run `knoxcall login` first"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
client_opts = { bootstrap: StoredCredentials.new(path: path, profile: profile) }
|
|
34
|
+
client_opts[:base_url] = options[:base_url] if CredentialsFile.presence(options[:base_url])
|
|
35
|
+
client_opts[:sandbox] = true if options[:sandbox]
|
|
36
|
+
client = Client.new(**client_opts)
|
|
37
|
+
|
|
38
|
+
account = client.account.get || {}
|
|
39
|
+
tenant = CredentialsFile.presence(account["name"]) ||
|
|
40
|
+
CredentialsFile.presence(account["company_name"]) ||
|
|
41
|
+
CredentialsFile.presence(account["slug"]) || "(unknown)"
|
|
42
|
+
puts "Signed in as #{tenant}."
|
|
43
|
+
|
|
44
|
+
# One-shot escrow mode: --provider selects it; the other bits are then required.
|
|
45
|
+
if CredentialsFile.presence(options[:provider])
|
|
46
|
+
return escrow_mode(client, options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
scaffold_mode
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One-shot escrow: escrow the key (read from KNOXCALL_WRAP_SECRET, never a
|
|
53
|
+
# flag) then mint the gateway base_url. The raw key is never printed.
|
|
54
|
+
def escrow_mode(client, options)
|
|
55
|
+
name = options[:secret_name].to_s.strip
|
|
56
|
+
host = options[:host].to_s.strip.downcase
|
|
57
|
+
value = ENV["KNOXCALL_WRAP_SECRET"]
|
|
58
|
+
raise Error, "--secret-name is required with --provider" if name.empty?
|
|
59
|
+
raise Error, "--host is required with --provider" if host.empty?
|
|
60
|
+
if value.nil? || value.empty?
|
|
61
|
+
raise Error, "set the provider key in the KNOXCALL_WRAP_SECRET env var (not a flag)"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
client.wrap.escrow(provider: options[:provider], name: name, value: value, hosts: [host])
|
|
65
|
+
res = client.wrap.gateway_url(secret: name, host: host)
|
|
66
|
+
puts ""
|
|
67
|
+
puts "Escrowed '#{name}' for #{host} — your provider key is now in KnoxCall custody."
|
|
68
|
+
puts "Point a base-URL-only SDK at:"
|
|
69
|
+
puts " #{res['base_url']}"
|
|
70
|
+
puts ""
|
|
71
|
+
puts "…or transport-wrap an SDK that takes an injected Faraday connection:"
|
|
72
|
+
puts " knox = KnoxCall::Client.new # your KnoxCall key"
|
|
73
|
+
puts " conn = knox.wrap.faraday_connection(url: \"https://#{host}\")"
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Scaffold mode: print the two-step quickstart, no writes.
|
|
78
|
+
def scaffold_mode
|
|
79
|
+
puts ""
|
|
80
|
+
puts "Wrap a provider SDK through KnoxCall in two steps:"
|
|
81
|
+
puts ""
|
|
82
|
+
puts "1) Move the provider key into custody (key via KNOXCALL_WRAP_SECRET):"
|
|
83
|
+
puts " KNOXCALL_WRAP_SECRET=sk_live_… \\"
|
|
84
|
+
puts " knoxcall init --provider stripe --secret-name wrap-stripe --host api.stripe.com"
|
|
85
|
+
puts ""
|
|
86
|
+
puts "2) Route your SDK through KnoxCall (the key never re-enters your process):"
|
|
87
|
+
puts " knox = KnoxCall::Client.new # your KnoxCall key"
|
|
88
|
+
puts " conn = knox.wrap.faraday_connection(url: \"https://api.stripe.com\")"
|
|
89
|
+
puts " # …or point a base-URL-only SDK at the base_url that step 1 prints."
|
|
90
|
+
0
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|