kward 0.79.0 → 0.80.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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. metadata +26 -1
@@ -0,0 +1,291 @@
1
+ require "fileutils"
2
+ require "open3"
3
+ require "pathname"
4
+
5
+ require_relative "path_guard"
6
+
7
+ # Namespace for the Kward CLI agent runtime.
8
+ module Kward
9
+ # Durable association between a session tab and a linked Git worktree.
10
+ class GitWorktreeBinding
11
+ FIELDS = %i[repository_root origin_root path branch base_revision active owned].freeze
12
+
13
+ attr_accessor :repository_root, :origin_root, :path, :branch, :base_revision, :active, :owned
14
+
15
+ def initialize(repository_root:, origin_root:, path:, branch:, base_revision:, active: true, owned: true)
16
+ @repository_root = repository_root.to_s
17
+ @origin_root = origin_root.to_s
18
+ @path = path.to_s
19
+ @branch = branch.to_s
20
+ @base_revision = base_revision.to_s
21
+ @active = active != false
22
+ @owned = owned != false
23
+ end
24
+
25
+ def active?
26
+ active == true
27
+ end
28
+
29
+ def descriptor
30
+ {
31
+ "repository_root" => repository_root,
32
+ "origin_root" => origin_root,
33
+ "path" => path,
34
+ "branch" => branch,
35
+ "base_revision" => base_revision,
36
+ "active" => active?,
37
+ "owned" => owned != false
38
+ }
39
+ end
40
+
41
+ def self.from_descriptor(value)
42
+ data = value.respond_to?(:transform_keys) ? value.transform_keys(&:to_s) : {}
43
+ required = %w[repository_root origin_root path branch base_revision]
44
+ return nil unless required.all? { |key| !data[key].to_s.empty? }
45
+
46
+ new(
47
+ repository_root: data["repository_root"],
48
+ origin_root: data["origin_root"],
49
+ path: data["path"],
50
+ branch: data["branch"],
51
+ base_revision: data["base_revision"],
52
+ active: data.fetch("active", true),
53
+ owned: data.fetch("owned", true)
54
+ )
55
+ end
56
+ end
57
+
58
+ # Performs safe, structured operations on linked Git worktrees.
59
+ class GitWorktreeManager
60
+ class Error < StandardError; end
61
+
62
+ WorktreeInfo = Struct.new(
63
+ :path,
64
+ :head,
65
+ :branch,
66
+ :detached,
67
+ :locked,
68
+ :prunable,
69
+ keyword_init: true
70
+ )
71
+
72
+ GitStatus = Struct.new(:entries, keyword_init: true) do
73
+ def clean?
74
+ entries.empty?
75
+ end
76
+
77
+ def dirty?
78
+ !clean?
79
+ end
80
+ end
81
+
82
+ MergeResult = Struct.new(:status, :output, :conflicts, keyword_init: true) do
83
+ def merged?
84
+ status == :merged
85
+ end
86
+
87
+ def conflicted?
88
+ status == :conflicted
89
+ end
90
+ end
91
+
92
+ def repository_root(path)
93
+ output = run_git(path, "rev-parse", "--show-toplevel")
94
+ canonical_existing_path(output.strip)
95
+ rescue Error
96
+ raise Error, "Not a Git repository: #{path}"
97
+ end
98
+
99
+ def status(path)
100
+ output = run_git(path, "status", "--porcelain=v1", "--untracked-files=all")
101
+ GitStatus.new(entries: output.lines(chomp: true))
102
+ end
103
+
104
+ def current_revision(path)
105
+ run_git(path, "rev-parse", "HEAD").strip
106
+ end
107
+
108
+ def current_branch(path)
109
+ run_git(path, "branch", "--show-current").strip
110
+ end
111
+
112
+ def merge(repository_root:, target_path:, source_branch:)
113
+ repository_root = canonical_existing_path(repository_root)
114
+ target_path = canonical_existing_path(target_path)
115
+ source_branch = source_branch.to_s.strip
116
+ validate_merge!(repository_root, target_path, source_branch)
117
+
118
+ output, status = capture_git(target_path, "merge", "--no-edit", source_branch)
119
+ return MergeResult.new(status: :merged, output: output, conflicts: []) if status.success?
120
+
121
+ conflicts = unmerged_paths(target_path)
122
+ return MergeResult.new(status: :conflicted, output: output, conflicts: conflicts) if merge_in_progress?(target_path)
123
+
124
+ raise Error, git_error("merge", output)
125
+ end
126
+
127
+ def merge_in_progress?(path)
128
+ _output, status = capture_git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD")
129
+ status.success?
130
+ end
131
+
132
+ def unmerged_paths(path)
133
+ run_git(path, "diff", "--name-only", "--diff-filter=U").lines(chomp: true)
134
+ end
135
+
136
+ def abort_merge(path)
137
+ raise Error, "No merge is in progress." unless merge_in_progress?(path)
138
+
139
+ run_git(path, "merge", "--abort")
140
+ true
141
+ end
142
+
143
+ def create(repository_root:, origin_root:, path:, branch:, base: "HEAD")
144
+ repository_root = canonical_existing_path(repository_root)
145
+ origin_root = canonical_existing_path(origin_root)
146
+ path = planned_path(path)
147
+ branch = branch.to_s.strip
148
+ base = base.to_s.strip
149
+ validate_creation!(repository_root, origin_root, path, branch, base)
150
+
151
+ run_git(repository_root, "worktree", "add", "-b", branch, path, base)
152
+ GitWorktreeBinding.new(
153
+ repository_root: repository_root,
154
+ origin_root: origin_root,
155
+ path: path,
156
+ branch: branch,
157
+ base_revision: revision_for(repository_root, base),
158
+ active: true,
159
+ owned: true
160
+ )
161
+ end
162
+
163
+ def inspect(repository_root:, path:)
164
+ repository_root = canonical_existing_path(repository_root)
165
+ expected_path = planned_path(path)
166
+ worktree = list(repository_root).find { |entry| same_path?(entry.path, expected_path) }
167
+ raise Error, "Git worktree is not registered: #{expected_path}" unless worktree
168
+
169
+ worktree
170
+ end
171
+
172
+ def list(repository_root)
173
+ output = run_git(repository_root, "worktree", "list", "--porcelain")
174
+ parse_worktree_list(output)
175
+ end
176
+
177
+ def remove(repository_root:, path:, force: false)
178
+ repository_root = canonical_existing_path(repository_root)
179
+ expected_path = planned_path(path)
180
+ inspect(repository_root: repository_root, path: expected_path)
181
+ arguments = ["worktree", "remove"]
182
+ arguments << "--force" if force
183
+ arguments << expected_path
184
+ run_git(repository_root, *arguments)
185
+ true
186
+ end
187
+
188
+ private
189
+
190
+ def validate_merge!(repository_root, target_path, source_branch)
191
+ raise Error, "Merge target must be inside the repository: #{target_path}" unless PathGuard.inside?(target_path, repository_root)
192
+ raise Error, "Source branch is required" if source_branch.empty?
193
+ raise Error, "Invalid source branch: #{source_branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", source_branch)
194
+ raise Error, "Source branch does not exist: #{source_branch}" unless valid_revision?(repository_root, source_branch)
195
+ raise Error, "A merge is already in progress: #{target_path}" if merge_in_progress?(target_path)
196
+ end
197
+
198
+ def validate_creation!(repository_root, origin_root, path, branch, base)
199
+ raise Error, "Worktree path must be outside the repository: #{path}" if PathGuard.inside?(path, repository_root)
200
+ raise Error, "Worktree origin must be inside the repository: #{origin_root}" unless PathGuard.inside?(origin_root, repository_root)
201
+ raise Error, "Branch name is required" if branch.empty?
202
+ raise Error, "Base revision is required" if base.empty?
203
+ raise Error, "Invalid branch name: #{branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", branch)
204
+ raise Error, "Invalid base revision: #{base}" unless valid_revision?(repository_root, base)
205
+ raise Error, "Worktree path is a file: #{path}" if File.file?(path)
206
+ raise Error, "Worktree path is not empty: #{path}" if File.directory?(path) && !Dir.empty?(path)
207
+ raise Error, "Worktree path is already registered: #{path}" if list(repository_root).any? { |entry| same_path?(entry.path, path) }
208
+
209
+ FileUtils.mkdir_p(File.dirname(path))
210
+ end
211
+
212
+ def valid_revision?(repository_root, revision)
213
+ return false if revision.start_with?("-") || revision.match?(/\s/)
214
+
215
+ git_succeeds?(repository_root, "rev-parse", "--verify", "#{revision}^{commit}")
216
+ end
217
+
218
+ def revision_for(repository_root, revision)
219
+ run_git(repository_root, "rev-parse", "--verify", "#{revision}^{commit}").strip
220
+ end
221
+
222
+ def parse_worktree_list(output)
223
+ output.split(/\n\n+/).filter_map do |block|
224
+ fields = block.lines(chomp: true).each_with_object({}) do |line, values|
225
+ key, value = line.split(" ", 2)
226
+ values[key] = value.to_s unless key.nil? || key.empty?
227
+ end
228
+ path = fields["worktree"]
229
+ next if path.to_s.empty?
230
+
231
+ branch = fields["branch"].to_s.delete_prefix("refs/heads/")
232
+ WorktreeInfo.new(
233
+ path: path,
234
+ head: fields["HEAD"].to_s,
235
+ branch: branch.empty? ? nil : branch,
236
+ detached: fields.key?("detached"),
237
+ locked: fields.key?("locked"),
238
+ prunable: fields.key?("prunable")
239
+ )
240
+ end
241
+ end
242
+
243
+ def run_git(root, *arguments)
244
+ output, status = capture_git(root, *arguments)
245
+ return output if status.success?
246
+
247
+ raise Error, git_error(arguments.join(" "), output)
248
+ end
249
+
250
+ def capture_git(root, *arguments)
251
+ Open3.capture2e("git", "-C", root.to_s, *arguments)
252
+ rescue Errno::ENOENT => e
253
+ raise Error, e.message
254
+ end
255
+
256
+ def git_error(command, output)
257
+ message = output.to_s.strip
258
+ message = "git #{command} failed" if message.empty?
259
+ message
260
+ end
261
+
262
+ def git_succeeds?(root, *arguments)
263
+ _output, status = Open3.capture2e("git", "-C", root.to_s, *arguments)
264
+ status.success?
265
+ rescue Errno::ENOENT
266
+ false
267
+ end
268
+
269
+ def canonical_existing_path(path)
270
+ File.realpath(path.to_s)
271
+ rescue Errno::ENOENT, Errno::ENOTDIR
272
+ raise Error, "Path does not exist: #{path}"
273
+ end
274
+
275
+ def planned_path(path)
276
+ expanded = Pathname.new(path.to_s).expand_path
277
+ return File.realpath(expanded.to_s) if File.exist?(expanded.to_s)
278
+
279
+ ancestor = expanded
280
+ ancestor = ancestor.parent until File.exist?(ancestor.to_s) || ancestor == ancestor.parent
281
+ canonical_ancestor = Pathname.new(File.realpath(ancestor.to_s))
282
+ canonical_ancestor.join(expanded.relative_path_from(ancestor)).to_s
283
+ rescue ArgumentError, Errno::ENOENT, Errno::ENOTDIR
284
+ raise Error, "Invalid worktree path: #{path}"
285
+ end
286
+
287
+ def same_path?(left, right)
288
+ planned_path(left) == planned_path(right)
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,54 @@
1
+ require "uri"
2
+
3
+ # Namespace for Kward model-provider support.
4
+ module Kward
5
+ # Validated Azure OpenAI endpoint and deployment settings.
6
+ class AzureOpenAIConfig
7
+ DEPLOYMENT_PATTERN = /\A[a-zA-Z0-9._-]+\z/
8
+ API_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
9
+
10
+ def initialize(endpoint:, deployment:, api_version:)
11
+ @endpoint = normalize_endpoint(endpoint)
12
+ @deployment = validate_value(deployment, "deployment name", DEPLOYMENT_PATTERN)
13
+ @api_version = validate_value(api_version, "API version", API_VERSION_PATTERN)
14
+ end
15
+
16
+ attr_reader :endpoint, :deployment, :api_version
17
+
18
+ def chat_completions_url
19
+ URI("#{endpoint}/openai/deployments/#{deployment}/chat/completions?api-version=#{api_version}")
20
+ end
21
+
22
+ def to_config
23
+ {
24
+ "azure_openai_endpoint" => endpoint,
25
+ "azure_openai_model" => deployment,
26
+ "azure_openai_api_version" => api_version
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def normalize_endpoint(value)
33
+ text = value.to_s.strip
34
+ raise ArgumentError, "Azure OpenAI endpoint must be a non-empty HTTPS URL" if text.empty?
35
+
36
+ uri = URI.parse(text)
37
+ unless uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? && uri.user.nil? && uri.password.nil? && uri.query.nil? && uri.fragment.nil?
38
+ raise ArgumentError, "Azure OpenAI endpoint must be an HTTPS URL without credentials, query, or fragment"
39
+ end
40
+
41
+ uri.to_s.sub(%r{/+\z}, "")
42
+ rescue URI::InvalidURIError
43
+ raise ArgumentError, "Azure OpenAI endpoint must be a valid HTTPS URL"
44
+ end
45
+
46
+ def validate_value(value, label, pattern)
47
+ text = value.to_s.strip
48
+ raise ArgumentError, "Azure OpenAI #{label} must be a non-empty string" if text.empty?
49
+ raise ArgumentError, "Azure OpenAI #{label} contains invalid characters" unless text.match?(pattern)
50
+
51
+ text
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,113 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "time"
4
+ require "uri"
5
+ require_relative "../config_files"
6
+ require_relative "../http"
7
+ require_relative "../private_file"
8
+ require_relative "provider_catalog"
9
+
10
+ # Namespace for Kward model catalogs.
11
+ module Kward
12
+ # Fetches, normalizes, and caches provider model lists without storing secrets.
13
+ class ModelCatalog
14
+ VERSION = 1
15
+ attr_reader :path
16
+
17
+ def initialize(provider_id:, api_key:, path: nil, requester: nil)
18
+ @provider = ProviderCatalog.fetch(provider_id)
19
+ @api_key = api_key.to_s
20
+ @path = File.expand_path(path || ConfigFiles.model_catalog_cache_path(@provider.id))
21
+ @requester = requester
22
+ end
23
+
24
+ def refresh
25
+ raise "No #{@provider.name} API key found" if @api_key.empty?
26
+ raise "#{@provider.name} does not support automatic model discovery" unless runtime.automatic_model_discovery?
27
+
28
+ data = cache_data(normalize(response_body))
29
+ PrivateFile.write_json(path, data)
30
+ data.fetch("models")
31
+ end
32
+
33
+ def models
34
+ Array(read&.fetch("models", []))
35
+ end
36
+
37
+ def read
38
+ return nil unless File.exist?(path)
39
+
40
+ data = JSON.parse(File.read(path))
41
+ return nil unless data.is_a?(Hash) && data["version"] == VERSION && data["provider"] == @provider.id
42
+
43
+ data
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def response_body
51
+ return @requester.call(url, headers) if @requester
52
+
53
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
54
+ http.request(Http.apply_user_agent(Net::HTTP::Get.new(url)).tap do |request|
55
+ headers.each { |name, value| request[name] = value }
56
+ end)
57
+ end
58
+ raise "#{@provider.name} model refresh failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
59
+
60
+ response.body
61
+ end
62
+
63
+ def url
64
+ base = runtime.model_list_url
65
+ return URI("#{base}?key=#{URI.encode_www_form_component(@api_key)}") if @provider.id == "gemini"
66
+
67
+ URI(base)
68
+ end
69
+
70
+ def runtime
71
+ ProviderCatalog.runtime(@provider.id)
72
+ end
73
+
74
+ def headers
75
+ return { "x-api-key" => @api_key, "anthropic-version" => "2023-06-01" } if @provider.id == "anthropic"
76
+ return {} if @provider.id == "gemini"
77
+
78
+ { "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json" }
79
+ end
80
+
81
+ def normalize(body)
82
+ payload = JSON.parse(body.to_s)
83
+ entries = payload["data"] || payload["models"] || []
84
+ Array(entries).filter_map { |entry| normalize_entry(entry) }.sort_by { |entry| entry["id"] }
85
+ rescue JSON::ParserError
86
+ raise "#{@provider.name} model refresh returned invalid JSON"
87
+ end
88
+
89
+ def normalize_entry(entry)
90
+ return unless entry.is_a?(Hash)
91
+
92
+ id = (entry["id"] || entry["name"]).to_s.delete_prefix("models/")
93
+ return if id.empty?
94
+
95
+ {
96
+ "provider" => @provider.id,
97
+ "id" => id,
98
+ "name" => entry["display_name"] || entry["displayName"] || entry["name"] || id,
99
+ "contextWindow" => entry["context_window"] || entry["context_length"] || entry["inputTokenLimit"],
100
+ "supportedParameters" => Array(entry["supported_parameters"] || entry["supportedGenerationMethods"])
101
+ }.compact
102
+ end
103
+
104
+ def cache_data(models)
105
+ {
106
+ "version" => VERSION,
107
+ "provider" => @provider.id,
108
+ "refreshed_at" => Time.now.utc.iso8601,
109
+ "models" => models
110
+ }
111
+ end
112
+ end
113
+ end