kie-ruby 0.1.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/agents/project-environment-setup.md +149 -0
  3. data/.claude/commands/soba/implement.md +93 -0
  4. data/.claude/commands/soba/plan.md +98 -0
  5. data/.claude/commands/soba/review.md +91 -0
  6. data/.claude/commands/soba/revise.md +79 -0
  7. data/.devcontainer/LICENSE +21 -0
  8. data/.devcontainer/README.md +85 -0
  9. data/.devcontainer/bin/devcontainer-common.sh +36 -0
  10. data/.devcontainer/bin/down +55 -0
  11. data/.devcontainer/bin/init +159 -0
  12. data/.devcontainer/bin/rebuild +10 -0
  13. data/.devcontainer/bin/up +11 -0
  14. data/.devcontainer/compose.yaml +12 -0
  15. data/.devcontainer/compose.yaml.template +8 -0
  16. data/.devcontainer/devcontainer.json +30 -0
  17. data/.devcontainer/devcontainer.local.json +3 -0
  18. data/.devcontainer/scripts/setup.sh +12 -0
  19. data/.lefthook/rubocop-autofix.sh +51 -0
  20. data/.soba/config.yml +78 -0
  21. data/CHANGELOG.md +23 -0
  22. data/CLAUDE.md +20 -0
  23. data/LICENSE +21 -0
  24. data/README.md +310 -0
  25. data/Rakefile +12 -0
  26. data/docs/business/INDEX.md +3 -0
  27. data/docs/business/overview.md +35 -0
  28. data/docs/development/INDEX.md +3 -0
  29. data/docs/development/technical-design.md +77 -0
  30. data/docs/document_system.md +58 -0
  31. data/lefthook.yml +8 -0
  32. data/lib/kie/client.rb +36 -0
  33. data/lib/kie/errors.rb +43 -0
  34. data/lib/kie/general_engine.rb +56 -0
  35. data/lib/kie/general_task.rb +106 -0
  36. data/lib/kie/middleware/raise_error.rb +46 -0
  37. data/lib/kie/model_definition.rb +10 -0
  38. data/lib/kie/model_registry.rb +36 -0
  39. data/lib/kie/models/nano_banana_pro.rb +17 -0
  40. data/lib/kie/models/suno_v4.rb +14 -0
  41. data/lib/kie/models/suno_v4_5.rb +14 -0
  42. data/lib/kie/models/suno_v4_5_all.rb +14 -0
  43. data/lib/kie/models/suno_v4_5_plus.rb +14 -0
  44. data/lib/kie/models/suno_v5.rb +14 -0
  45. data/lib/kie/suno_engine.rb +129 -0
  46. data/lib/kie/suno_task.rb +99 -0
  47. data/lib/kie/task.rb +69 -0
  48. data/lib/kie/version.rb +5 -0
  49. data/lib/kie.rb +26 -0
  50. data/sig/kie.rbs +6 -0
  51. metadata +110 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kie
6
+ # Represents an asynchronous task for General API endpoints
7
+ class GeneralTask
8
+ include Task
9
+
10
+ RECORD_INFO_ENDPOINT = "/api/v1/jobs/recordInfo"
11
+
12
+ # Status mapping from General API raw states to normalized statuses
13
+ STATUS_MAP = {
14
+ waiting: :processing,
15
+ generating: :processing,
16
+ success: :success,
17
+ fail: :failed
18
+ }.freeze
19
+
20
+ attr_reader :task_id, :raw_status, :cost_time, :fail_code, :fail_message
21
+
22
+ alias id task_id
23
+
24
+ def initialize(client:, task_id:)
25
+ @client = client
26
+ @task_id = task_id
27
+ @raw_status = nil
28
+ @result_urls = []
29
+ @cost_time = nil
30
+ @fail_code = nil
31
+ @fail_message = nil
32
+ end
33
+
34
+ def refresh!
35
+ response = @client.connection.get(RECORD_INFO_ENDPOINT, taskId: @task_id)
36
+ handle_response(response)
37
+ self
38
+ end
39
+
40
+ # Returns normalized status (:processing, :success, :failed)
41
+ def status
42
+ return :processing if @raw_status.nil?
43
+
44
+ STATUS_MAP.fetch(@raw_status, :processing)
45
+ end
46
+
47
+ # Returns array of result URLs
48
+ def urls
49
+ @result_urls
50
+ end
51
+
52
+ # Alias for backwards compatibility
53
+ alias result_urls urls
54
+
55
+ # Returns the error message from the failed task, or nil if not failed
56
+ def error_message
57
+ @fail_message
58
+ end
59
+
60
+ # Returns true when task is in pending/waiting state (not yet processing)
61
+ def pending?
62
+ @raw_status.nil? || @raw_status == :waiting
63
+ end
64
+
65
+ # Raises the appropriate exception based on the failure reason
66
+ def raise_task_error!
67
+ error_class = @fail_code == "CONTENT_POLICY" ? ContentPolicyError : TaskFailedError
68
+ raise error_class.new(@fail_message, fail_code: @fail_code)
69
+ end
70
+
71
+ private
72
+
73
+ def handle_response(response)
74
+ data = JSON.parse(response.body)
75
+ handle_api_error(data) unless data["code"] == 200
76
+
77
+ update_from_data(data["data"])
78
+ end
79
+
80
+ def handle_api_error(data)
81
+ case data["code"]
82
+ when 404
83
+ raise NotFoundError.new(data["msg"], status_code: data["code"], response_body: data.to_json)
84
+ else
85
+ raise ApiError.new(data["msg"], status_code: data["code"], response_body: data.to_json)
86
+ end
87
+ end
88
+
89
+ def update_from_data(data)
90
+ @raw_status = data["state"]&.to_sym
91
+ @cost_time = data["costTime"]
92
+ @fail_code = data["failCode"]
93
+ @fail_message = data["failMsg"]
94
+ parse_result_json(data["resultJson"])
95
+ end
96
+
97
+ def parse_result_json(result_json)
98
+ return if result_json.nil? || result_json.empty?
99
+
100
+ parsed = JSON.parse(result_json)
101
+ @result_urls = parsed["resultUrls"] || []
102
+ rescue JSON::ParserError
103
+ @result_urls = []
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ module Kie
7
+ module Middleware
8
+ # Faraday middleware that raises Kie-specific exceptions based on HTTP status codes
9
+ class RaiseError < Faraday::Middleware
10
+ ERROR_MAP = {
11
+ 400 => InvalidParametersError,
12
+ 401 => AuthenticationError,
13
+ 402 => InsufficientCreditsError,
14
+ 404 => NotFoundError,
15
+ 422 => InvalidParametersError,
16
+ 429 => RateLimitError,
17
+ 455 => ServiceUnavailableError
18
+ }.freeze
19
+
20
+ def on_complete(env)
21
+ return if env.success?
22
+
23
+ error_class = determine_error_class(env.status)
24
+ message = extract_message(env.body, env.status)
25
+
26
+ raise error_class.new(message, status_code: env.status, response_body: env.body)
27
+ end
28
+
29
+ private
30
+
31
+ def determine_error_class(status)
32
+ return ERROR_MAP[status] if ERROR_MAP.key?(status)
33
+ return ServerError if status >= 500
34
+
35
+ ApiError
36
+ end
37
+
38
+ def extract_message(body, status)
39
+ data = JSON.parse(body)
40
+ data["msg"] || "HTTP #{status}"
41
+ rescue JSON::ParserError
42
+ "HTTP #{status}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kie
4
+ ModelDefinition = Struct.new(:name, :category, :input_constraints, :parameters, keyword_init: true) do
5
+ def initialize(...)
6
+ super
7
+ freeze
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kie
4
+ # Central registry for model definitions
5
+ class ModelRegistry
6
+ class << self
7
+ def register(definition)
8
+ registry[definition.name] = definition
9
+ end
10
+
11
+ def find(model_name)
12
+ registry.fetch(model_name) do
13
+ raise UnknownModelError, "Unknown model: #{model_name}"
14
+ end
15
+ end
16
+
17
+ def registered?(model_name)
18
+ registry.key?(model_name)
19
+ end
20
+
21
+ def all
22
+ registry.values
23
+ end
24
+
25
+ def clear
26
+ @registry = {}
27
+ end
28
+
29
+ private
30
+
31
+ def registry
32
+ @registry ||= {}
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kie::ModelRegistry.register(
4
+ Kie::ModelDefinition.new(
5
+ name: "nano-banana-pro",
6
+ category: :general,
7
+ input_constraints: {
8
+ prompt: { type: :string, max_length: 20_000, required: true },
9
+ images: { type: :array, max_items: 8, max_file_size: 30_000_000, formats: %w[jpeg png webp], required: false }
10
+ },
11
+ parameters: {
12
+ aspect_ratio: { type: :enum, values: %w[1:1 2:3 3:2 3:4 4:3 4:5 5:4 9:16 16:9 21:9 auto], default: "1:1" },
13
+ resolution: { type: :enum, values: %w[1K 2K 4K], default: "1K" },
14
+ output_format: { type: :enum, values: %w[png jpg], default: "png" }
15
+ }
16
+ )
17
+ )
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kie::ModelRegistry.register(
4
+ Kie::ModelDefinition.new(
5
+ name: "V4",
6
+ category: :suno,
7
+ input_constraints: {
8
+ prompt: { type: :string, max_length: 3000, max_length_non_custom: 500 },
9
+ style: { type: :string, max_length: 200 },
10
+ title: { type: :string, max_length: 80 }
11
+ },
12
+ parameters: {}
13
+ )
14
+ )
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kie::ModelRegistry.register(
4
+ Kie::ModelDefinition.new(
5
+ name: "V4_5",
6
+ category: :suno,
7
+ input_constraints: {
8
+ prompt: { type: :string, max_length: 5000, max_length_non_custom: 500 },
9
+ style: { type: :string, max_length: 1000 },
10
+ title: { type: :string, max_length: 80 }
11
+ },
12
+ parameters: {}
13
+ )
14
+ )
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kie::ModelRegistry.register(
4
+ Kie::ModelDefinition.new(
5
+ name: "V4_5ALL",
6
+ category: :suno,
7
+ input_constraints: {
8
+ prompt: { type: :string, max_length: 5000, max_length_non_custom: 500 },
9
+ style: { type: :string, max_length: 1000 },
10
+ title: { type: :string, max_length: 80 }
11
+ },
12
+ parameters: {}
13
+ )
14
+ )
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kie::ModelRegistry.register(
4
+ Kie::ModelDefinition.new(
5
+ name: "V4_5PLUS",
6
+ category: :suno,
7
+ input_constraints: {
8
+ prompt: { type: :string, max_length: 5000, max_length_non_custom: 500 },
9
+ style: { type: :string, max_length: 1000 },
10
+ title: { type: :string, max_length: 80 }
11
+ },
12
+ parameters: {}
13
+ )
14
+ )
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kie::ModelRegistry.register(
4
+ Kie::ModelDefinition.new(
5
+ name: "V5",
6
+ category: :suno,
7
+ input_constraints: {
8
+ prompt: { type: :string, max_length: 5000, max_length_non_custom: 500 },
9
+ style: { type: :string, max_length: 1000 },
10
+ title: { type: :string, max_length: 80 }
11
+ },
12
+ parameters: {}
13
+ )
14
+ )
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kie
6
+ # Engine for Suno API endpoints (/api/v1/generate) - Suno music generation
7
+ class SunoEngine
8
+ CREATE_TASK_ENDPOINT = "/api/v1/generate"
9
+
10
+ OPTIONAL_FIELD_MAPPING = {
11
+ callback_url: :callBackUrl,
12
+ negative_tags: :negativeTags,
13
+ vocal_gender: :vocalGender,
14
+ style_weight: :styleWeight,
15
+ weirdness_constraint: :weirdnessConstraint,
16
+ audio_weight: :audioWeight,
17
+ persona_id: :personaId
18
+ }.freeze
19
+ private_constant :OPTIONAL_FIELD_MAPPING
20
+
21
+ def initialize(client:)
22
+ @client = client
23
+ end
24
+
25
+ def generate(model:, input:, **)
26
+ definition = ModelRegistry.find(model)
27
+ validate_input!(definition, input)
28
+
29
+ response = @client.connection.post(CREATE_TASK_ENDPOINT) do |req|
30
+ req.body = build_request_body(definition, input)
31
+ end
32
+
33
+ handle_response(response)
34
+ end
35
+
36
+ private
37
+
38
+ def validate_input!(definition, input)
39
+ constraints = definition.input_constraints
40
+ custom_mode = input[:custom_mode] || false
41
+
42
+ validate_prompt!(constraints, input, custom_mode)
43
+ validate_style!(constraints, input) if custom_mode && input[:style]
44
+ validate_title!(constraints, input) if custom_mode && input[:title]
45
+ end
46
+
47
+ def validate_prompt!(constraints, input, custom_mode)
48
+ prompt = input[:prompt]
49
+ return unless prompt && constraints[:prompt]
50
+
51
+ max_length = if custom_mode
52
+ constraints[:prompt][:max_length]
53
+ else
54
+ constraints[:prompt][:max_length_non_custom] || constraints[:prompt][:max_length]
55
+ end
56
+
57
+ return unless prompt.length > max_length
58
+
59
+ raise ValidationError, "prompt exceeds maximum length of #{max_length} characters"
60
+ end
61
+
62
+ def validate_style!(constraints, input)
63
+ style = input[:style]
64
+ return unless style && constraints[:style]
65
+
66
+ max_length = constraints[:style][:max_length]
67
+ return unless style.length > max_length
68
+
69
+ raise ValidationError, "style exceeds maximum length of #{max_length} characters"
70
+ end
71
+
72
+ def validate_title!(constraints, input)
73
+ title = input[:title]
74
+ return unless title && constraints[:title]
75
+
76
+ max_length = constraints[:title][:max_length]
77
+ return unless title.length > max_length
78
+
79
+ raise ValidationError, "title exceeds maximum length of #{max_length} characters"
80
+ end
81
+
82
+ def build_request_body(definition, input)
83
+ body = {
84
+ model: definition.name,
85
+ customMode: input[:custom_mode] || false,
86
+ instrumental: input[:instrumental] || false,
87
+ prompt: input[:prompt],
88
+ callBackUrl: input[:callback_url] || "https://example.invalid/callback"
89
+ }
90
+
91
+ add_custom_mode_fields(body, input) if input[:custom_mode]
92
+ add_optional_fields(body, input)
93
+
94
+ body.to_json
95
+ end
96
+
97
+ def add_custom_mode_fields(body, input)
98
+ body[:style] = input[:style] if input[:style]
99
+ body[:title] = input[:title] if input[:title]
100
+ end
101
+
102
+ def add_optional_fields(body, input)
103
+ OPTIONAL_FIELD_MAPPING.each do |input_key, api_key|
104
+ body[api_key] = input[input_key] if input[input_key]
105
+ end
106
+ end
107
+
108
+ def handle_response(response)
109
+ data = JSON.parse(response.body)
110
+ handle_api_error(data) unless data["code"] == 200
111
+
112
+ SunoTask.new(client: @client, task_id: data["data"]["taskId"])
113
+ end
114
+
115
+ def handle_api_error(data)
116
+ error_class = error_class_for_code(data["code"])
117
+ raise error_class.new(data["msg"], status_code: data["code"], response_body: data.to_json)
118
+ end
119
+
120
+ def error_class_for_code(code)
121
+ case code
122
+ when 400, 422 then InvalidParametersError
123
+ when 402 then InsufficientCreditsError
124
+ when 429 then RateLimitError
125
+ else ApiError
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kie
6
+ # Represents an asynchronous task for Suno API endpoints (Suno music generation)
7
+ class SunoTask
8
+ include Task
9
+
10
+ RECORD_INFO_ENDPOINT = "/api/v1/generate/record-info"
11
+
12
+ # Status mapping from Suno API raw states to normalized statuses
13
+ STATUS_MAP = {
14
+ PENDING: :processing,
15
+ TEXT_SUCCESS: :processing,
16
+ FIRST_SUCCESS: :processing,
17
+ SUCCESS: :success,
18
+ CREATE_TASK_FAILED: :failed,
19
+ GENERATE_AUDIO_FAILED: :failed,
20
+ CALLBACK_EXCEPTION: :failed,
21
+ SENSITIVE_WORD_ERROR: :failed
22
+ }.freeze
23
+
24
+ attr_reader :task_id, :raw_status, :error_code, :error_message, :suno_data, :audio_urls, :image_urls
25
+
26
+ alias id task_id
27
+
28
+ def initialize(client:, task_id:)
29
+ @client = client
30
+ @task_id = task_id
31
+ @raw_status = nil
32
+ @suno_data = []
33
+ @audio_urls = []
34
+ @image_urls = []
35
+ @error_code = nil
36
+ @error_message = nil
37
+ end
38
+
39
+ def refresh!
40
+ response = @client.connection.get(RECORD_INFO_ENDPOINT, taskId: @task_id)
41
+ handle_response(response)
42
+ self
43
+ end
44
+
45
+ # Returns normalized status (:processing, :success, :failed)
46
+ def status
47
+ return :processing if @raw_status.nil?
48
+
49
+ STATUS_MAP.fetch(@raw_status, :processing)
50
+ end
51
+
52
+ # Returns array of audio URLs (implements Task interface)
53
+ def urls
54
+ @audio_urls
55
+ end
56
+
57
+ # Raises the appropriate exception based on the failure reason
58
+ def raise_task_error!
59
+ error_class = @raw_status == :SENSITIVE_WORD_ERROR ? ContentPolicyError : TaskFailedError
60
+ raise error_class.new(@error_message, fail_code: @raw_status.to_s)
61
+ end
62
+
63
+ private
64
+
65
+ def handle_response(response)
66
+ data = JSON.parse(response.body)
67
+ handle_api_error(data) unless data["code"] == 200
68
+
69
+ update_from_data(data["data"])
70
+ end
71
+
72
+ def handle_api_error(data)
73
+ case data["code"]
74
+ when 404
75
+ raise NotFoundError.new(data["msg"], status_code: data["code"], response_body: data.to_json)
76
+ else
77
+ raise ApiError.new(data["msg"], status_code: data["code"], response_body: data.to_json)
78
+ end
79
+ end
80
+
81
+ def update_from_data(data)
82
+ @raw_status = data["status"]&.to_sym
83
+ @error_code = data["errorCode"]
84
+ @error_message = data["errorMessage"]
85
+ parse_response(data["response"])
86
+ end
87
+
88
+ def parse_response(response)
89
+ return if response.nil?
90
+
91
+ suno_data = response["sunoData"]
92
+ return if suno_data.nil? || suno_data.empty?
93
+
94
+ @suno_data = suno_data
95
+ @audio_urls = suno_data.filter_map { |item| item["audioUrl"] }
96
+ @image_urls = suno_data.filter_map { |item| item["imageUrl"] }
97
+ end
98
+ end
99
+ end
data/lib/kie/task.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kie
4
+ # Common interface module for all task types (General API, Suno API, etc.)
5
+ # Classes including this module must implement:
6
+ # - #status: Returns normalized status symbol (:processing, :success, :failed)
7
+ # - #urls: Returns array of result URLs
8
+ module Task
9
+ NORMALIZED_STATUSES = %i[processing success failed].freeze
10
+
11
+ def status
12
+ raise NotImplementedError, "#{self.class} must implement #status"
13
+ end
14
+
15
+ def urls
16
+ raise NotImplementedError, "#{self.class} must implement #urls"
17
+ end
18
+
19
+ def error_message
20
+ raise NotImplementedError, "#{self.class} must implement #error_message"
21
+ end
22
+
23
+ def completed?
24
+ success? || failed?
25
+ end
26
+
27
+ def success?
28
+ status == :success
29
+ end
30
+
31
+ def failed?
32
+ status == :failed
33
+ end
34
+
35
+ def processing?
36
+ status == :processing
37
+ end
38
+
39
+ # Waits for the task to complete by polling refresh!
40
+ # @param timeout [Numeric] Maximum time to wait in seconds (default: 300)
41
+ # @param interval [Numeric] Time between polling attempts in seconds (default: 2)
42
+ # @param raise_on_failure [Boolean] Whether to raise an exception on task failure (default: true)
43
+ # @return [self] Returns the task instance for chaining
44
+ # @raise [TimeoutError] if the timeout is exceeded
45
+ # @raise [TaskFailedError, ContentPolicyError] if the task fails and raise_on_failure is true
46
+ def wait(timeout: 300, interval: 2, raise_on_failure: true)
47
+ deadline = Time.now + timeout
48
+ poll_until_completed(deadline: deadline, interval: interval, raise_on_failure: raise_on_failure)
49
+ end
50
+
51
+ private
52
+
53
+ def poll_until_completed(deadline:, interval:, raise_on_failure:)
54
+ loop do
55
+ refresh!
56
+ return self if success?
57
+ return handle_failure(raise_on_failure) if failed?
58
+ raise TimeoutError, "Task did not complete within timeout" if Time.now > deadline
59
+
60
+ sleep interval
61
+ end
62
+ end
63
+
64
+ def handle_failure(raise_on_failure)
65
+ raise_task_error! if raise_on_failure
66
+ self
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kie
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kie.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kie/version"
4
+
5
+ module Kie
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "kie/errors"
10
+ require_relative "kie/middleware/raise_error"
11
+ require_relative "kie/task"
12
+ require_relative "kie/model_definition"
13
+ require_relative "kie/model_registry"
14
+ require_relative "kie/client"
15
+ require_relative "kie/general_task"
16
+ require_relative "kie/general_engine"
17
+ require_relative "kie/suno_task"
18
+ require_relative "kie/suno_engine"
19
+
20
+ # Model definitions
21
+ require_relative "kie/models/nano_banana_pro"
22
+ require_relative "kie/models/suno_v4"
23
+ require_relative "kie/models/suno_v4_5"
24
+ require_relative "kie/models/suno_v4_5_plus"
25
+ require_relative "kie/models/suno_v4_5_all"
26
+ require_relative "kie/models/suno_v5"
data/sig/kie.rbs ADDED
@@ -0,0 +1,6 @@
1
+ module Kie
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+ end