Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions doc/AI_TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,17 @@ in a Recipe they are interchangeable at the same position:
interval: 2
```

```yaml
- module: FilterKimi
config:
token: YOUR_KIMI_API_KEY
model: kimi-k3
prompt: |
以下の記事群を一つのダイジェストとして日本語で要約してください。
retry: 2
interval: 2
```

Nothing before or after the swapped entry changes. `token`, `model` and
`prompt` are shared required settings of the AI filters; `max_tokens` exists
only for Claude because that API requires it. Model names move with the services, so take them from the provider
Expand Down
2 changes: 1 addition & 1 deletion doc/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ bundle. `plugins` is every group in the first block at once.
| `PublishInstapaper` | an Instapaper account; no gem | — | — |
| `PublishEject` | the `eject` or `drutil` command | — | — |
| `NotifyIkachan` | an `ikachan` gateway you run | — | — |
| `FilterOpenAI`, `FilterClaude`, `FilterGemini`, `FilterSakuraAI` | an account and an API token with that one service; no gem | — | — |
| `FilterOpenAI`, `FilterClaude`, `FilterGemini`, `FilterSakuraAI`, `FilterKimi` | an account and an API token with that one service; no gem | — | — |
| `PublishHatenaBookmark` | the current Hatena API, which it does not speak | — | — |

Membership of the aggregate `plugins` group is defined in `Gemfile`. It
Expand Down
43 changes: 41 additions & 2 deletions doc/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,45 @@ Recipe that says where the text goes for a Recipe that does not.
interval: 2
```

#### FilterKimi — **Supported (external)**

`filter/kimi.rb`. Sends each item's description to Moonshot AI's Kimi,
`https://api.moonshot.ai/v1/chat/completions`, and replaces it with the
answer. The token is a bearer token, and the request is the chat completions
form: the prompt as a `system` message, the description as a `user` message.

| Key | Type | Meaning |
| --- | --- | --- |
| `token` | string | Kimi API key. Required. |
| `model` | string | Model name, as Moonshot names it (e.g. `kimi-k3`). Required. |
| `prompt` | string | The instruction, sent as the `system` message. Required. |
| `retry` | integer | Attempts after a failure. Default `0`. |
| `interval` | integer | Seconds between attempts. Default `0`. |

**This interface is OpenAI-compatible, and this is still its own plugin.** It is
a different service: a different endpoint, a different account, a different set
of models, its own limits and its own errors, any of which may move without
OpenAI moving. Folding it into `FilterOpenAI` behind a setting would trade a
Recipe that says where the text goes for a Recipe that does not, the same
reasoning that already keeps Sakura AI in a plugin of its own.

Kimi may return a `reasoning_content` alongside the answer's `content`. That
reasoning is never read, logged or written anywhere; only `content` is used,
and only once `finish_reason` is `"stop"` -- any other value, or a missing or
empty `content`, is an error rather than an empty description.

```yaml
- module: FilterKimi
config:
token: YOUR_KIMI_API_KEY
model: kimi-k3
prompt: |
以下の記事群について、個別記事の要約を羅列するのではなく、
全体を一つのダイジェストとして日本語で要約してください。
retry: 2
interval: 2
```

A digest, end to end: find the articles, drop the ones already seen, fetch
their bodies, strip the markup, join them, ask once, write the answer out.

Expand Down Expand Up @@ -1414,8 +1453,8 @@ plugins:
```

Changing service is changing the one entry: `FilterSakuraAI` for
`FilterOpenAI`, `FilterClaude` or `FilterGemini`, with that plugin's own
settings. Nothing before or after it changes.
`FilterOpenAI`, `FilterClaude`, `FilterGemini` or `FilterKimi`, with that
plugin's own settings. Nothing before or after it changes.

### 6.4 Store

Expand Down
2 changes: 2 additions & 0 deletions doc/VERSIONS
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ automaticruby Repository Version History

v26.09 (Release Date: TBD)
--------------------------
- Add FilterKimi for the Kimi API, using its OpenAI-compatible Chat Completions
interface.
- Add FilterLimit, FilterBatch, FilterPresent, and per-fetch interval handling;
make doc/PLUGINS.md section 6 the plugin catalogue's source of truth.
- Harden the framework's execution boundary: fix CLI contract drift, distinguish
Expand Down
216 changes: 216 additions & 0 deletions plugins/filter/kimi.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# Name:: Automatic::Plugin::Filter::Kimi
# Description:: Replace each item's description with what the Kimi API answers.
# Author: id774 (More info: https://id774.net)
# Source Code:: https://github.com/id774/automaticruby
# License:: The GPL version 3, or LGPL version 3 (Dual License).
# Contact:: idnanashi@gmail.com
# Created:: Sep 11, 2026
# Updated:: Sep 11, 2026
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
#
# One transformation: the item's description goes to the Kimi API under the
# Recipe's prompt, and the answer becomes the item's description. What that
# transformation is -- a summary, a translation, an extraction, a
# classification -- is the prompt's business, not this plugin's.
#
# Moonshot AI's Kimi offers an OpenAI-compatible chat completions interface,
# and this plugin is still its own rather than a mode of FilterOpenAI. It is a
# different service: a different endpoint, a different account, a different
# set of models, its own limits and its own errors, and any of those may move
# without OpenAI moving. A Recipe naming FilterKimi says which service the
# text is sent to, which a `provider:` setting would not.
#
# Kimi may return a `reasoning_content` alongside the answer's own `content`.
# That reasoning is never read, logged or written anywhere by this plugin --
# only `content` is the answer, and only once `finish_reason` says the model
# is done.
#
# @see https://platform.moonshot.ai/docs/api/chat

module Automatic::Plugin
class FilterKimi
require 'json'
require 'net/http'
require 'openssl'
require 'uri'

# The one endpoint the service publishes for this. It is not a setting: an
# operator has no version of this plugin that talks to a different host,
# and a setting for it would be a way to send the token somewhere else.
ENDPOINT = URI('https://api.moonshot.ai/v1/chat/completions')

OPEN_TIMEOUT = 10

# Generous, and bounded. A model given several articles thinks for a while;
# an unattended run that waits forever is the failure this exists against.
READ_TIMEOUT = 300

# A failure that another attempt will not get past: a setting that is
# wrong, a request the service refuses, an answer this plugin cannot read.
class Error < StandardError; end

# A failure that another attempt may get past: the network, a rate limit, a
# server error.
class TemporaryError < StandardError; end

def initialize(config, pipeline = [])
@config = config || {}
@pipeline = pipeline
end

# Replaces each item's description with the answer. Nothing else about an
# item is touched, and the feeds and their items arrive and leave in the
# same number and the same order.
def run
validate_settings

@pipeline.each { |feeds|
next if feeds.nil?

feeds.items.each { |item| transform(item) }
}
@pipeline
end

private

# Checked before the first request, because a Recipe this plugin cannot
# carry out is the operator's mistake and will be the same mistake on every
# item. The token is never named in a message.
def validate_settings
raise ArgumentError, 'FilterKimi needs a token' if token.empty?
raise ArgumentError, 'FilterKimi needs a model' if model.empty?
raise ArgumentError, 'FilterKimi needs a prompt' if prompt.empty?
end

def token
@config['token'].to_s
end

def model
@config['model'].to_s.strip
end

def prompt
@config['prompt'].to_s.strip
end

def transform(item)
text = item.description.to_s
if text.strip.empty?
Automatic::Log.puts('warn', "FilterKimi: nothing to send for #{item.link}")
return
end

Automatic::Log.puts('info', "FilterKimi: asking #{model} about #{item.link}")
item.description = answer(text)
end

# The retry shape of doc/PLUGINS.md section 3.6, applied only to what
# retrying can help. A missing setting, a refused request or an answer in a
# shape this plugin cannot read is raised at once: trying again would fail
# the same way, more slowly.
def answer(text)
retries = 0
retry_max = @config['retry'].to_i
begin
completion(text)
rescue TemporaryError => e
retries += 1
Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterKimi: #{e.message}")
if retries <= retry_max
sleep(@config['interval'].to_i)
retry
end
raise Error, "FilterKimi gave up after #{retries} attempts: #{e.message}"
end
end

def completion(text)
# The prompt is the system turn and the description is the user turn it
# is applied to. They are separate messages, so that what an article says
# is never read as an instruction to this plugin or to the model. Nothing
# else -- no sampling or tool-use parameter -- is sent.
body = {
'model' => model,
'messages' => [
{ 'role' => 'system', 'content' => prompt },
{ 'role' => 'user', 'content' => text }
]
}
content(post(JSON.generate(body)))
end

def post(body)
request = Net::HTTP::Post.new(ENDPOINT)
request['Authorization'] = "Bearer #{token}"
request['Content-Type'] = 'application/json'
request.body = body

# TLS with the certificate verified, which is Net::HTTP's own default and
# is named here because it is not a thing to be turned off.
Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
use_ssl: true,
verify_mode: OpenSSL::SSL::VERIFY_PEER,
open_timeout: OPEN_TIMEOUT,
read_timeout: READ_TIMEOUT) { |http| http.request(request) }
rescue Timeout::Error, SystemCallError, SocketError, IOError,
OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
raise TemporaryError, "the request to Kimi failed: #{e.message}"
end

def content(response)
case response
when Net::HTTPSuccess
answer_text(parse(response.body))
when Net::HTTPTooManyRequests, Net::HTTPServerError
raise TemporaryError, "Kimi answered #{response.code}: #{reason(response)}"
else
raise Error, "Kimi answered #{response.code}: #{reason(response)}"
end
end

def parse(body)
JSON.parse(body.to_s)
rescue JSON::ParserError => e
raise Error, "Kimi answered with something that is not JSON: #{e.message}"
end

# The first choice's message content, once the model says it is actually
# done. `reasoning_content`, when Kimi sends one alongside `content`, is
# never read here: it is not the answer, and it never becomes one. An
# answer this plugin cannot find is an error and not an empty description:
# a Recipe that published the empty string here would have thrown the
# article away and reported success.
def answer_text(body)
choices = body['choices']
unless choices.is_a?(Array) && choices.first.is_a?(Hash)
raise Error, 'Kimi answered without a choice'
end

choice = choices.first
message = choice['message']
raise Error, 'Kimi answered without a message' unless message.is_a?(Hash)
raise Error, "Kimi did not finish: #{choice['finish_reason']}" unless choice['finish_reason'] == 'stop'

text = message['content'].to_s.strip
raise Error, 'Kimi answered with no content' if text.empty?

text
end

# The service's own explanation where it gave one, the status line
# otherwise. Neither carries the token, and the settings are never logged
# or raised wholesale.
def reason(response)
body = JSON.parse(response.body.to_s)
error = body['error']
return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?

response.message.to_s
rescue JSON::ParserError
response.message.to_s
end
end
end
Loading
Loading