k9o 1.2.0 → 1.4.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.
data/lib/k9o/ai.rb CHANGED
@@ -7,6 +7,8 @@ module K9o
7
7
  # Wire (project-plane):
8
8
  # POST /@<slug>/ai/embeddings body { model?, input: String | [String] }
9
9
  # -> { data: { embeddings: [[Float]], model:, dimensions: }, meta }
10
+ # POST /@<slug>/ai/generate body { prompt:, model?, maxTokens?, temperature?, usageType? }
11
+ # -> { data: { modelId:, content:, finishReason:, usage:, usageType: }, meta }
10
12
  #
11
13
  # Bounds are enforced by the gateway, not here. The SDK sends `input`
12
14
  # exactly as given and never puts it in an error.
@@ -24,5 +26,18 @@ module K9o
24
26
  HttpTransport.request(config: @config, method: 'POST',
25
27
  path: UrlGrammar.project_path(slug, 'ai/embeddings'), body: body)
26
28
  end
29
+
30
+ # POST /@<slug>/ai/generate — one synchronous text generation (verb sugar over compute.llms.invoke).
31
+ # Only the fields given are sent; without `model` the gateway uses the org's default LLM.
32
+ def generate(prompt, model: nil, max_tokens: nil, temperature: nil, usage_type: nil)
33
+ slug = Studio.require_project_slug(@config, 'ai')
34
+ body = { 'prompt' => prompt }
35
+ body['model'] = model unless model.nil?
36
+ body['maxTokens'] = max_tokens unless max_tokens.nil?
37
+ body['temperature'] = temperature unless temperature.nil?
38
+ body['usageType'] = usage_type unless usage_type.nil?
39
+ HttpTransport.request(config: @config, method: 'POST',
40
+ path: UrlGrammar.project_path(slug, 'ai/generate'), body: body)
41
+ end
27
42
  end
28
43
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Shared argument checks for the approvals, approval policy and schema namespaces. A bad value raises
5
+ # K9o::KError K9O_INVALID_FIELD_VALUE before any request is sent.
6
+ module ApprovalChecks
7
+ module_function
8
+
9
+ def invalid(message, context = {})
10
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
11
+ end
12
+
13
+ def check_limit(limit)
14
+ return if limit.nil?
15
+ return if limit.is_a?(Integer) && limit >= 1 && limit <= 200
16
+
17
+ raise invalid('limit must be a whole number from 1 to 200', limit: limit)
18
+ end
19
+
20
+ def id_segment(value, what)
21
+ raise invalid("#{what} id must be a non-empty string") unless value.is_a?(String) && !value.empty?
22
+
23
+ ::URI.encode_www_form_component(value).gsub('+', '%20')
24
+ end
25
+
26
+ def reason!(reason)
27
+ raise invalid('reason is required and must be a non-empty string') unless reason.is_a?(String) && !reason.strip.empty?
28
+
29
+ reason
30
+ end
31
+
32
+ def predicate!(value, field)
33
+ raise invalid("#{field} must be a predicate object") unless value.is_a?(Hash)
34
+
35
+ value
36
+ end
37
+
38
+ def enum!(value, allowed, field)
39
+ text = value.is_a?(Symbol) ? value.to_s : value
40
+ return text if allowed.include?(text)
41
+
42
+ raise invalid("#{field} must be #{allowed.map { |a| "\"#{a}\"" }.join(', ')}", field.to_sym => value)
43
+ end
44
+ end
45
+
46
+ # Pending-change queue: k9o.approvals.*
47
+ #
48
+ # list(status:, limit:) GET /@<slug>/pending-approvals
49
+ # get(id) GET /@<slug>/pending-approvals/<id>
50
+ # approve(id, reason:) POST /@<slug>/pending-approvals/<id>/approve
51
+ # reject(id, reason:) POST /@<slug>/pending-approvals/<id>/reject
52
+ # bulk(action:, criteria:, reason:) POST /@<slug>/pending-approvals/bulk
53
+ #
54
+ # +list+ is not paginated: it returns the newest +limit+ changes (1 to 200, default 50). +approve+ can answer 200
55
+ # with +status+ still +pending_approval+: the approval is recorded and more approvers are needed. +bulk+ skips rows
56
+ # that fail without a report, so compare +affectedIds+ with what you intended.
57
+ class Approvals
58
+ def initialize(config)
59
+ @config = config
60
+ end
61
+
62
+ # Pending changes, newest first. Only +limit+ caps the result.
63
+ def list(status: nil, limit: nil)
64
+ ApprovalChecks.check_limit(limit)
65
+ query = {}
66
+ query['status'] = status.to_s unless status.nil?
67
+ query['limit'] = limit unless limit.nil?
68
+ call('GET', path, query: query)
69
+ end
70
+
71
+ # One pending change. Members who are not queue admins can read only their own.
72
+ def get(id)
73
+ call('GET', path("/#{ApprovalChecks.id_segment(id, 'approval')}"))
74
+ end
75
+
76
+ # Approve a pending change. Read +status+ on the answer: +pending_approval+ means more approvers are needed.
77
+ def approve(id, reason: nil)
78
+ seg = ApprovalChecks.id_segment(id, 'approval')
79
+ call('POST', path("/#{seg}/approve"), body: reason.nil? ? {} : { 'reason' => reason })
80
+ end
81
+
82
+ # Reject a pending change. +reason+ is required and must not be empty.
83
+ def reject(id, reason:)
84
+ seg = ApprovalChecks.id_segment(id, 'approval')
85
+ call('POST', path("/#{seg}/reject"), body: { 'reason' => ApprovalChecks.reason!(reason) })
86
+ end
87
+
88
+ # Approve or reject every pending change matching +criteria+ (a predicate Hash). +reason+ is required to reject.
89
+ # Rows that fail are skipped without a report: compare +affectedIds+ with what you intended.
90
+ def bulk(action:, criteria:, reason: nil)
91
+ act = ApprovalChecks.enum!(action, %w[approve reject], 'action')
92
+ body = { 'action' => act, 'criteria' => ApprovalChecks.predicate!(criteria, 'criteria') }
93
+ if act == 'reject'
94
+ body['reason'] = ApprovalChecks.reason!(reason)
95
+ elsif !reason.nil?
96
+ body['reason'] = reason
97
+ end
98
+ call('POST', path('/bulk'), body: body)
99
+ end
100
+
101
+ private
102
+
103
+ def call(method, route, body: nil, query: nil)
104
+ HttpTransport.request(config: @config, method: method, path: route, body: body, query: query)
105
+ end
106
+
107
+ def path(tail = '')
108
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals#{tail}")
109
+ end
110
+ end
111
+
112
+ # Approval policies: k9o.approval_policies.*
113
+ #
114
+ # list GET /@<slug>/approval-policies
115
+ # create(collection:, operation:, action:, predicate:) POST /@<slug>/approval-policies
116
+ # delete(id) DELETE /@<slug>/approval-policies/<id>
117
+ class ApprovalPolicies
118
+ COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
119
+
120
+ def initialize(config)
121
+ @config = config
122
+ end
123
+
124
+ # All policies, oldest first. The first matching policy for a write decides.
125
+ def list
126
+ call('GET', path)
127
+ end
128
+
129
+ # Create a policy. Identical policies are not de-duplicated.
130
+ def create(collection:, operation:, action:, predicate:)
131
+ unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
132
+ raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$', collection: collection)
133
+ end
134
+
135
+ body = {
136
+ 'collection' => collection,
137
+ 'operation' => ApprovalChecks.enum!(operation, %w[insert update delete], 'operation'),
138
+ 'action' => ApprovalChecks.enum!(action, %w[auto_approve queue], 'action'),
139
+ 'predicate' => ApprovalChecks.predicate!(predicate, 'predicate')
140
+ }
141
+ call('POST', path, body: body)
142
+ end
143
+
144
+ # Delete a policy; the answer is { 'id' => ... }.
145
+ def delete(id)
146
+ call('DELETE', path("/#{ApprovalChecks.id_segment(id, 'policy')}"))
147
+ end
148
+
149
+ private
150
+
151
+ def call(method, route, body: nil)
152
+ HttpTransport.request(config: @config, method: method, path: route, body: body)
153
+ end
154
+
155
+ def path(tail = '')
156
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approval_policies'), "approval-policies#{tail}")
157
+ end
158
+ end
159
+ end
data/lib/k9o/audit.rb CHANGED
@@ -2,27 +2,25 @@
2
2
 
3
3
  module K9o
4
4
  # Project-plane audit log namespace — k9o.audit.*.
5
- # Distinct from k9o.org.audit_log + k9o.me.audit_log.
5
+ # Distinct from k9o.org.audit_log.
6
6
  class Audit
7
7
  def initialize(config)
8
8
  @config = config
9
9
  end
10
10
 
11
- # Query the audit log (reverse-chronological; filters AND-compose).
12
- # Accepts keyword filters: since/until/type/actor_id/target_kind/
13
- # target_id/limit/cursor (wire camelCase mapping applied here).
14
- def query(since: nil, until_: nil, type: nil, actor_id: nil,
15
- target_kind: nil, target_id: nil, limit: nil, cursor: nil)
11
+ # GET /@<slug>/audit-log — reverse-chronological; filters AND-compose. Requires the AUDIT_READER role.
12
+ # event_type: audit-event name prefix (e.g. 'data.write.access') actor_id: filter by actor
13
+ # after / before: ISO 8601 bounds (events after / before this instant)
14
+ # limit: page size (bounded server-side) cursor: from the prior response's meta.cursor.next
15
+ def query(event_type: nil, actor_id: nil, after: nil, before: nil, limit: nil, cursor: nil)
16
16
  slug = Studio.require_project_slug(@config, 'audit')
17
17
  q = {}
18
- q['since'] = since if since
19
- q['until'] = until_ if until_
20
- q['type'] = type if type
21
- q['actorId'] = actor_id if actor_id
22
- q['targetKind'] = target_kind if target_kind
23
- q['targetId'] = target_id if target_id
24
- q['limit'] = limit if limit
25
- q['cursor'] = cursor if cursor
18
+ q['eventType'] = event_type unless event_type.nil?
19
+ q['actorId'] = actor_id unless actor_id.nil?
20
+ q['after'] = after unless after.nil?
21
+ q['before'] = before unless before.nil?
22
+ q['limit'] = limit unless limit.nil?
23
+ q['cursor'] = cursor unless cursor.nil?
26
24
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'audit-log'), query: q)
27
25
  end
28
26
  end
data/lib/k9o/client.rb CHANGED
@@ -3,8 +3,10 @@
3
3
  module K9o
4
4
  # The customer-facing client returned by K9o.create_client.
5
5
  class K9oClient
6
- attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :org,
7
- :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
6
+ include QueryMethods
7
+
8
+ attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :streams, :org,
9
+ :approvals, :approval_policies, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
8
10
 
9
11
  def initialize(config)
10
12
  @config = config
@@ -13,6 +15,10 @@ module K9o
13
15
  @studio = Studio.new(config)
14
16
  @compute = Compute.new(config)
15
17
  @realtime = Realtime.new(config)
18
+ @streams = Streams.new(config)
19
+ @approvals = Approvals.new(config)
20
+ @approval_policies = ApprovalPolicies.new(config)
21
+ @schema = Schema.new(config)
16
22
  @org = Org.new(config)
17
23
  @orgs = Orgs.new(config)
18
24
  @migrations = Migrations.new(config)
@@ -36,17 +42,15 @@ module K9o
36
42
 
37
43
  # Factory — construct a K9oClient bound to the supplied options.
38
44
  #
39
- # Per spec sample:
40
45
  # k9o = K9o.create_client(
41
46
  # api_key: ENV["K9O_API_KEY"],
42
47
  # org_slug: "acme",
43
48
  # project_slug: "member-portal",
44
49
  # )
45
50
  #
46
- # base_url is OPTIONAL. When omitted it
47
- # defaults to the platform apex https://api.k9o.io; when the host is the
48
- # bare apex and org_slug is set, the canonical org-subdomain wire root
49
- # https://<org_slug>.api.k9o.io is composed. Any other host passes through.
51
+ # base_url is OPTIONAL. When omitted it defaults to the public-cloud platform host (a convenience only, never used
52
+ # to parse a host). When the host is a platform host (+api.<apex>+ or +api-staging.<apex>+, any apex) and org_slug
53
+ # is set, the org host +<org_slug>.<host>+ is composed. Any other host passes through unchanged.
50
54
  def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil)
51
55
  normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
52
56
  config = HttpClientConfig.new(
data/lib/k9o/compute.rb CHANGED
@@ -4,30 +4,37 @@ module K9o
4
4
  # Compute namespace (Compute LLMs registry).
5
5
  # k9o.compute.llms.*
6
6
  class Compute
7
- # Curated 9-model closed enum (extensible v2.x).
7
+ # The curated model ids (closed enum).
8
8
  LLM_MODEL_IDS = %w[
9
9
  mistral-7b mistral-8x7b llama-3-8b llama-3-70b
10
10
  claude-3-5-sonnet gpt-4o gpt-4o-mini
11
11
  text-embedding-3-small text-embedding-3-large
12
12
  ].freeze
13
13
 
14
+ # Inference parameters the gateway reads (top-level body fields next to +prompt+). Snake_case spellings map to
15
+ # the gateway's.
16
+ PARAM_FIELDS = {
17
+ 'max_tokens' => 'maxTokens', 'maxTokens' => 'maxTokens',
18
+ 'temperature' => 'temperature',
19
+ 'usage_type' => 'usageType', 'usageType' => 'usageType'
20
+ }.freeze
21
+
14
22
  attr_reader :llms
15
23
 
16
24
  def initialize(config)
17
25
  @llms = Llms.new(config)
18
26
  end
19
27
 
20
- # k9o.compute.llms.*.
28
+ # k9o.compute.llms.*
21
29
  class Llms
22
30
  def initialize(config)
23
31
  @config = config
24
32
  end
25
33
 
26
- def list(modality: nil)
34
+ # GET /@<slug>/compute/llms
35
+ def list
27
36
  slug = Studio.require_project_slug(@config, 'compute.llms')
28
- q = modality ? { 'modality' => modality } : nil
29
- HttpTransport.request(config: @config, method: 'GET',
30
- path: UrlGrammar.project_path(slug, 'compute/llms'), query: q)
37
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'compute/llms'))
31
38
  end
32
39
 
33
40
  def read(model_id)
@@ -36,29 +43,39 @@ module K9o
36
43
  path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}"))
37
44
  end
38
45
 
39
- # Synchronous inference (PROJECT_AUTHOR/ADMIN).
46
+ # Synchronous inference: POST /@<slug>/compute/llms/<id>/invoke { prompt, maxTokens?, temperature?,
47
+ # usageType? }. +params+ takes max_tokens / temperature / usage_type (the gateway's spellings also work).
48
+ # The envelope's data is { 'modelId', 'content', 'finishReason', 'usage' => { 'promptTokens',
49
+ # 'completionTokens', 'totalTokens' }, 'usageType' }.
40
50
  def invoke(model_id, prompt:, params: nil)
41
51
  slug = Studio.require_project_slug(@config, 'compute.llms')
42
- body = { 'prompt' => prompt }
43
- body['params'] = params if params
44
52
  HttpTransport.request(config: @config, method: 'POST',
45
- path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"), body: body)
53
+ path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"),
54
+ body: invoke_body(prompt, params))
46
55
  end
47
56
 
48
- # Streaming inference (SSE per-token). Returns an
49
- # Enumerator of chunk hashes (Ruby idiom). Consume with
57
+ # Streaming inference (SSE per-token). Returns an Enumerator of chunk hashes. Consume with
50
58
  # `.each { |chunk| ... }`.
51
59
  def invoke_stream(model_id, prompt:, params: nil)
52
60
  slug = Studio.require_project_slug(@config, 'compute.llms')
53
- body = { 'prompt' => prompt }
54
- body['params'] = params if params
55
61
  HttpTransport.stream_sse(config: @config, method: 'POST',
56
62
  path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke-stream"),
57
- body: body)
63
+ body: invoke_body(prompt, params))
58
64
  end
59
65
 
60
66
  private
61
67
 
68
+ def invoke_body(prompt, params)
69
+ body = { 'prompt' => prompt }
70
+ (params || {}).each do |key, value|
71
+ wire = PARAM_FIELDS[key.to_s]
72
+ raise ArgumentError, "unknown inference parameter: #{key}" if wire.nil?
73
+
74
+ body[wire] = value unless value.nil?
75
+ end
76
+ body
77
+ end
78
+
62
79
  def enc(s)
63
80
  ::URI.encode_www_form_component(s)
64
81
  end
data/lib/k9o/data.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module K9o
4
6
  # Data-plane DSL.
5
7
  #
@@ -113,13 +115,77 @@ module K9o
113
115
  end
114
116
 
115
117
  class SelectBuilder < BaseBuilder
118
+ SEARCH_COLUMN = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
119
+ SEARCH_LANGUAGE = /\A[a-zA-Z_]+\z/.freeze
120
+ TEXT_TYPES = %w[plain phrase websearch].freeze
121
+ VECTOR_METRICS = %w[cosine euclidean dot].freeze
122
+
123
+ def initialize(config, collection)
124
+ super
125
+ @search = {}
126
+ end
127
+
128
+ # Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
129
+ def text_search(column, query, language: 'english', type: 'plain')
130
+ check_search_column(column)
131
+ raise search_error('text search query must be a non-empty string') unless query.is_a?(String) && !query.empty?
132
+ unless language.is_a?(String) && language.match?(SEARCH_LANGUAGE)
133
+ raise search_error('text search language must be letters and underscores only')
134
+ end
135
+
136
+ type = type.to_s
137
+ raise search_error("text search type must be one of #{TEXT_TYPES.join(', ')}") unless TEXT_TYPES.include?(type)
138
+
139
+ @search.delete('textSearchLanguage')
140
+ @search.delete('textSearchType')
141
+ @search['textSearch'] = "#{column}:#{query}"
142
+ @search['textSearchLanguage'] = language unless language == 'english'
143
+ @search['textSearchType'] = type unless type == 'plain'
144
+ self
145
+ end
146
+
147
+ # Nearest-neighbour search over one vector column; results come back closest first.
148
+ # +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10).
149
+ def vector_search(column, embedding, metric: 'cosine', limit: nil)
150
+ check_search_column(column)
151
+ unless embedding.is_a?(Array) && !embedding.empty? &&
152
+ embedding.all? { |v| v.is_a?(Integer) || (v.is_a?(Float) && v.finite?) }
153
+ raise search_error('embedding must be a non-empty array of finite numbers')
154
+ end
155
+
156
+ metric = metric.to_s
157
+ raise search_error("vector metric must be one of #{VECTOR_METRICS.join(', ')}") unless VECTOR_METRICS.include?(metric)
158
+ unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 1000)
159
+ raise search_error('vector limit must be an integer from 1 to 1000')
160
+ end
161
+
162
+ @search.delete('vectorMetric')
163
+ @search.delete('vectorLimit')
164
+ @search['vectorSearch'] = "#{column}:#{JSON.generate(embedding)}"
165
+ @search['vectorMetric'] = metric unless metric == 'cosine'
166
+ @search['vectorLimit'] = limit.to_s unless limit.nil?
167
+ self
168
+ end
169
+
116
170
  def execute
117
171
  HttpTransport.request(
118
172
  config: @config, method: 'GET',
119
173
  path: FromBuilder.collection_path(@config, @collection),
120
- query: to_query
174
+ query: to_query.merge(@search)
121
175
  )
122
176
  end
177
+
178
+ private
179
+
180
+ def search_error(message)
181
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
182
+ end
183
+
184
+ def check_search_column(column)
185
+ return if column.is_a?(String) && column.match?(SEARCH_COLUMN)
186
+
187
+ raise search_error('search column must be a column name: letters, digits and underscores, starting with a letter or underscore')
188
+ end
123
189
  end
124
190
 
125
191
  class UpdateBuilder < BaseBuilder
data/lib/k9o/functions.rb CHANGED
@@ -2,22 +2,52 @@
2
2
 
3
3
  module K9o
4
4
  # Function lifecycle namespace — k9o.functions.*.
5
+ #
6
+ # Upload the function's bytes first (+upload_artifact+ returns the +artifactKey+), then +deploy+ references that
7
+ # artifact. Deploy requires PROJECT_ADMIN and returns with status +deploying+; the function is ready when status
8
+ # flips to +active+.
5
9
  class Functions
10
+ ARTIFACT_CONTENT_TYPES = { 'wasm' => 'application/wasm', 'tar' => 'application/x-tar' }.freeze
11
+
6
12
  def initialize(config)
7
13
  @config = config
8
14
  end
9
15
 
10
- def deploy(name:, runtime:, code:, tier: nil, config: nil)
16
+ # POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (up to 16 MiB,
17
+ # +kind+ 'wasm') or an OCI image archive for the sandbox tier (up to 256 MiB, +kind+ 'tar'). Uploading the same
18
+ # bytes again returns the same key. The envelope's data is { 'artifactKey' => String }.
19
+ def upload_artifact(bytes, kind)
20
+ content_type = ARTIFACT_CONTENT_TYPES[kind.to_s]
21
+ raise ArgumentError, "kind must be 'wasm' or 'tar'" if content_type.nil?
22
+
23
+ bytes = bytes.read if bytes.respond_to?(:read)
24
+ raise ArgumentError, 'bytes must be a String or an IO' unless bytes.is_a?(String)
25
+
26
+ slug = require_project_slug
27
+ HttpTransport.request(config: @config, method: 'POST',
28
+ path: UrlGrammar.project_path(slug, 'functions/artifacts'),
29
+ raw_body: bytes, headers: { 'content-type' => content_type })
30
+ end
31
+
32
+ # POST /@<slug>/functions — deploy or redeploy from an uploaded artifact.
33
+ # runtime: bun python go java kotlin dotnet swift php ruby rust tinygo zig assemblyscript
34
+ # tier: 'gateway' (WebAssembly) or 'sandbox' (OCI image archive)
35
+ # artifact_key: the key +upload_artifact+ returned; version: label for this deploy (1 to 64 characters)
36
+ def deploy(name:, runtime:, tier:, artifact_key:, version:, description: nil)
11
37
  slug = require_project_slug
12
- body = { 'name' => name, 'runtime' => runtime, 'code' => code }
13
- body['tier'] = tier if tier
14
- body['config'] = config if config
38
+ body = { 'name' => name, 'runtime' => runtime, 'tier' => tier, 'artifactKey' => artifact_key,
39
+ 'version' => version }
40
+ body['description'] = description unless description.nil?
15
41
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'functions'), body: body)
16
42
  end
17
43
 
18
- def list(status: nil)
44
+ # GET /@<slug>/functions (cursor pagination).
45
+ def list(status: nil, limit: nil, cursor: nil)
19
46
  slug = require_project_slug
20
- q = status ? { 'status' => status } : nil
47
+ q = {}
48
+ q['status'] = status unless status.nil?
49
+ q['limit'] = limit unless limit.nil?
50
+ q['cursor'] = cursor unless cursor.nil?
21
51
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'functions'), query: q)
22
52
  end
23
53
 
@@ -26,16 +56,25 @@ module K9o
26
56
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
27
57
  end
28
58
 
59
+ # Retire a function; its name enters a 24h cooldown.
29
60
  def delete(name)
30
61
  slug = require_project_slug
31
62
  HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
32
63
  end
33
64
 
34
- # Invoke a function; payload passed to the runtime verbatim.
35
- def invoke(name, payload)
65
+ # POST /@<slug>/functions/<name>/invoke. +payload+ is sent as the +payload+ field of the request body and passed
66
+ # to the function runtime verbatim. Optional controls: +background+ (run asynchronously), +delegation+
67
+ # ({ 'token' => ..., 'invokeSessionId' => ... }), +on_behalf_of+, +confined_to+.
68
+ def invoke(name, payload = nil, background: nil, delegation: nil, on_behalf_of: nil, confined_to: nil)
36
69
  slug = require_project_slug
70
+ body = {}
71
+ body['payload'] = payload unless payload.nil?
72
+ body['background'] = background unless background.nil?
73
+ body['delegation'] = delegation unless delegation.nil?
74
+ body['onBehalfOf'] = on_behalf_of unless on_behalf_of.nil?
75
+ body['confinedTo'] = confined_to unless confined_to.nil?
37
76
  HttpTransport.request(config: @config, method: 'POST',
38
- path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: payload)
77
+ path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: body)
39
78
  end
40
79
 
41
80
  private
data/lib/k9o/gate.rb CHANGED
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- # Gate (Identity-Aware Kernel) namespace. Project-scoped.
4
+ # Gate (Identity-Aware Kernel) namespace. Project-scoped; the gateway closes each body to the fields shown.
5
5
  class Gate
6
6
  def initialize(config)
7
7
  @config = config
8
8
  end
9
9
 
10
+ # POST /@<slug>/gate/signup { email, password, displayName? }
10
11
  def sign_up(email:, password:, display_name: nil)
11
12
  body = { 'email' => email, 'password' => password }
12
13
  body['displayName'] = display_name if display_name
13
14
  HttpTransport.request(config: @config, method: 'POST', path: gate_path('signup'), body: body)
14
15
  end
15
16
 
17
+ # POST /@<slug>/gate/signin { email, password }
16
18
  def sign_in(email:, password:)
17
19
  HttpTransport.request(
18
20
  config: @config, method: 'POST', path: gate_path('signin'),
@@ -20,15 +22,18 @@ module K9o
20
22
  )
21
23
  end
22
24
 
23
- def sign_out(refresh_token: nil)
24
- body = refresh_token ? { 'refreshToken' => refresh_token } : {}
25
- HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'), body: body)
25
+ # POST /@<slug>/gate/signout { refreshToken } — the refresh token is required (the gateway answers 400 without it).
26
+ def sign_out(refresh_token:)
27
+ HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
28
+ body: { 'refreshToken' => refresh_token })
26
29
  end
27
30
 
31
+ # GET /@<slug>/gate/whoami
28
32
  def whoami
29
33
  HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
30
34
  end
31
35
 
36
+ # POST /@<slug>/gate/refresh { refreshToken }
32
37
  def refresh_token(refresh_token:)
33
38
  HttpTransport.request(
34
39
  config: @config, method: 'POST', path: gate_path('refresh'),
@@ -36,20 +41,6 @@ module K9o
36
41
  )
37
42
  end
38
43
 
39
- def verify_session
40
- HttpTransport.request(config: @config, method: 'GET', path: gate_path('verify'))
41
- end
42
-
43
- # The project's hosted sign-in URL and WebAuthn rpId:
44
- # GET /@<slug>/gate/hosted. Unauthenticated and cacheable; no credential is
45
- # sent even when an api_key is configured. Returns an Envelope whose `data`
46
- # is { 'signInUrl' => String, 'rpId' => String }. Take both values as given:
47
- # an unknown org, a deleted project and a non-Gate project all answer the
48
- # same 404 K9O_PROJECT_NOT_FOUND.
49
- def hosted
50
- HttpTransport.request(config: @config, method: 'GET', path: gate_path('hosted'), authenticated: false)
51
- end
52
-
53
44
  private
54
45
 
55
46
  def gate_path(action)
@@ -68,10 +68,11 @@ module K9o
68
68
  #
69
69
  # `authenticated: false` sends NO credential: no api_key bearer, and any
70
70
  # authorization / cookie header from `default_headers` or `headers:` is dropped.
71
- def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
71
+ def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
72
+ raw_body: nil)
72
73
  retry_with_backoff(method, config.retry_config || RetryConfig.default) do
73
74
  request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
74
- authenticated: authenticated, multipart: multipart)
75
+ authenticated: authenticated, multipart: multipart, raw_body: raw_body)
75
76
  end
76
77
  end
77
78
 
@@ -116,7 +117,8 @@ module K9o
116
117
  end
117
118
 
118
119
  # Single-attempt request. `request` wraps this in retry-with-backoff.
119
- def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
120
+ def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
121
+ raw_body: nil)
120
122
  url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
121
123
  if query && !query.empty?
122
124
  url += '?' + URI.encode_www_form(query)
@@ -149,7 +151,10 @@ module K9o
149
151
  req.delete('cookie')
150
152
  end
151
153
 
152
- if multipart
154
+ if raw_body
155
+ # Binary routes: the bytes go unchanged; the caller supplies the content-type in +headers+.
156
+ req.body = raw_body
157
+ elsif multipart
153
158
  # multipart/form-data: the encoder sets the boundary; never force JSON.
154
159
  data, content_type = Multipart.encode(multipart)
155
160
  req['content-type'] = content_type
@@ -215,7 +220,7 @@ module K9o
215
220
  # sentinel terminates the stream; non-2xx raises KError before any
216
221
  # event is yielded.
217
222
  #
218
- # Per the Ruby async-iteration idiom is Enumerator — caller
223
+ # The Ruby async-iteration idiom is Enumerator — caller
219
224
  # writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
220
225
  def stream_sse(config:, method:, path:, body: nil, query: nil)
221
226
  url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"